diff --git a/CHANGES.md b/CHANGES.md
index 2cb96daa..80ddaeee 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,6 +1,25 @@
# Changes
-## 10.11.0.0 08/03/2026
+## 10.11.0.0 08/04/2026
+
+* **`TestULedgerEntryFieldsConformance` — the third conformance surface**, completing the set next to `TestUTxFormatConformance` (transaction fields) and `TestULedgerFlagsConformance` (ledger flags). `ledger_entries.macro` is the only place the protocol states which fields belong to which ledger object — `definitions.json` carries field codes and object types but not the per-object lists — and nothing checked it. A missing field produces no symptom: reading the object still succeeds and the value is silently dropped, which is how `LOAccountRoot` went without `WalletLocator`/`WalletSize` until a manual pass, and how `sfLEVersion` had to arrive through a protocol-watch notification instead of a red test:
+ * `Tests/Xrpl.Tests/Fixtures/ledger_entries.macro`, vendored byte-identical and pinned by sha in the `.ref`. Pinned to a **develop** commit rather than a tag, unlike `LedgerFormats.h`: the models track develop for fields, and `sfLEVersion` exists only after 07/30/2026, so a tag would report it as a field the SDK invented
+ * both directions are diffed — a field rippled declares and the model lacks, and a property the model exposes that is not a field of that object — and every ledger object must be registered against a model, so a newly added one fails the build instead of being skipped
+ * rippled's four **common fields** (`LedgerIndex`, `LedgerEntryType`, `Flags`, `Sponsor` from `LedgerFormats::getCommonFields()`) are excluded on both sides, mirroring how the TxFormat guard treats `commonFields`; `[JsonIgnore]` properties (computed helpers like `DataParsed`, `MPTokenMetadataRow`) never reach the wire and are excluded too
+ * verified by mutation: renaming a field's `JsonPropertyName` makes it report both halves (`Loan.Borrower … missing from LOLoan` and `LOLoan.BorrowerX … not a field of Loan`)
+
+* **Ledger-object properties that are not protocol fields — removed** (**breaking**, no `[Obsolete]` grace period, consistent with the 10.10.0.0 removal of the inert `ConnectionOptions`). None of them could ever hold a value: rippled builds each object from a fixed `SOTemplate`, so a field outside the template cannot appear in it. Confirmed against a live node (nightly stand, 3.3.0-b1) *and* across four rippled versions — 3.2.1, 3.3.0-b1, 3.3.0-rc1 and develop — none of these exists in any of them, including the unreleased one:
+ * `LOVault.DomainID` — proven with a positive control: a `VaultCreate` carrying `Data`, `AssetsMaximum` **and** `DomainID` succeeded, the first two came back on the object, `DomainID` did not, and it turned up on the linked share `MPTokenIssuance` instead — exactly what the macro comment (`no PermissionedDomainID ever (use MPTIssuance.sfDomainID)`) and `VaultCreate.cpp` (`.domainId = tx[~sfDomainID]`) describe
+ * `LOLoan.PrincipalRequested` — a field of the **LoanSet transaction**, not of the object: a real loan created with `PrincipalRequested = 10000000` stores it as `PrincipalOutstanding`, and the object carries no such field
+ * `LOCredential.OwnerNode` — Credential hangs in two directories and uses `IssuerNode`/`SubjectNode`. Zero-valued directory hints *are* serialized (a Loan object returns `"OwnerNode":"0"`), so its absence is real, not a default being omitted
+ * `LONFTokenPage.NFTokenPage`, `LOAmm.LedgerCurrentIndex`, `LOAmm.Validated` — the last two are fields of the `amm_info` **response envelope** (`ledger_current_index`, `validated`, snake_case), not of the AMM object; `LOAmm` is only ever deserialized as a ledger object, and `amm_info` has its own `AMMInfo` model
+
+* **`LOAmm` fixes** — two bugs the guard surfaced:
+ * **`AMMAccount` never deserialized**: the AMM object's field is `Account`, and the property had no `[JsonPropertyName]`, so it silently stayed null on every AMM object ever read. Now mapped to `Account`; the property name is unchanged, so no call site breaks
+ * the constructor set `LedgerEntryType = LedgerEntryType.AccountRoot` — an AMM object identified itself as an AccountRoot. Now `LedgerEntryType.AMM`
+
+* **Fields declared by the protocol but missing from the models** — `PreviousTxnID`/`PreviousTxnLgrSeq` on `LOAmm`, `LOAmendments`, `LODirectoryNode`, `LOFeeSettings` and `LONegativeUNL`. Both are `SoeOptional` on these objects upstream; without them the transaction that last touched the object could not be read through the typed API
+
* **`sfLEVersion` — the Vault ledger entry's schema version** ([rippled #7817](https://github.com/XRPLF/rippled/pull/7817), merged into `develop` 07/30/2026, reported by protocol-watch). `UInt8` nth 6, `SoeDefault` on `ltVAULT`: it marks which accounting scheme a vault follows. Vaults created before cash-basis accounting was activated carry no `LEVersion` at all, and rippled resolves that absence as version 0 rather than an error — so an absent value is meaningful, not missing data:
* `definitions.json` + the generated `Field.Uint8` entry. **Both are required**: `definitions.json` is not read at runtime, it is the input to `Tools/GenerateEnums`, so a field added there alone travels nowhere. `TestULEVersion_BinaryRoundTrip` is what proves the round trip actually works rather than that the JSON was edited
* `LOVault.LEVersion` (`uint?`, matching the other UInt8 fields of that object) plus a `VaultVersion` enum naming the two values the protocol defines so far (`Legacy` = 0, `CashBasis` = 1)
@@ -29,7 +48,6 @@
* scenarios were derived from the transactor (`src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp` @ `3.3.0-rc1`), not from the docs — hence `tfMPTCanTransfer` at creation in the fee test: `preclaim` requires `lsfMPTCanTransfer` to be **already** set, and enabling it in the same transaction does not satisfy the rule
* amendment-gated, so it skips on the CI stand (rippled 3.2.x has `DynamicMPT` as `Supported::No`) and runs for real on the nightly stand
-## 10.10.1.0 08/02/2026
* **An exception from an `OnConnected` handler no longer kills the client forever** — `Connection.OnceOpen` caught anything thrown by a consumer `OnConnected` handler and called `Disconnect()`, i.e. the *user* disconnect path: it set `_permanentlyDisconnected = true` and called `ClearReconnectState()`. After that the client was dead — the reconnect loop was never restarted, no new socket was ever opened, `OnConnected` never fired again, and every later request threw `NotConnectedException("Client has been disconnected. Call Connect() to reconnect.")`. Nothing was logged and nothing was raised, so from the outside the client just went quiet:
* **The trigger is the most ordinary event there is — a node restart.** `OnConnected` is the natural place to restore subscriptions, because the SDK does not restore them after a reconnect. A restarting node accepts TCP seconds before it starts answering requests, so the first `subscribe` after the reconnect runs into `RequestTimeout` (40 s) and throws. A consumer that lets the exception out — the reasonable "fail loudly, let the SDK reconnect" reaction — got the opposite: a silent, permanent death. Observed in production on a fleet of bots, each wedged for four hours after a node upgrade, one of them dying 69 seconds before the node came back
* A failing handler is now treated as what it is — a **connection** failure, not a user disconnect. The socket is torn down and the regular reconnect loop takes over with its usual exponential backoff, exactly as for a transport failure. The permanent-disconnect flag is never set on this path
diff --git a/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro b/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro
new file mode 100644
index 00000000..b6408581
--- /dev/null
+++ b/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro
@@ -0,0 +1,641 @@
+#if !defined(LEDGER_ENTRY)
+#error "undefined macro: LEDGER_ENTRY"
+#endif
+
+#ifndef LEDGER_ENTRY_DUPLICATE
+// The EXPAND macro is needed for Windows
+// https://stackoverflow.com/questions/5134523/msvc-doesnt-expand-va-args-correctly
+#define EXPAND(x) x
+
+// The `LEDGER_ENTRY_DUPLICATE macro is needed to avoid JSS conflicts
+// Since some transactions and ledger entries have the same name (like `DepositPreauth`)
+// The compiler won't accept two instances of `JSS(DepositPreauth)`
+#define LEDGER_ENTRY_DUPLICATE(...) EXPAND(LEDGER_ENTRY(__VA_ARGS__))
+#endif
+
+/**
+ * These objects are listed in order of increasing ledger type ID.
+ * There are many gaps between these IDs.
+ * You are welcome to fill them with new object types.
+ */
+
+/** A ledger object which identifies an offer to buy or sell an NFT.
+
+ \sa keylet::nftokenOffer
+ */
+LEDGER_ENTRY(ltNFTOKEN_OFFER, 0x0037, NFTokenOffer, nft_offer, ({
+ {sfOwner, SoeRequired},
+ {sfNFTokenID, SoeRequired},
+ {sfAmount, SoeRequired},
+ {sfOwnerNode, SoeRequired},
+ {sfNFTokenOfferNode, SoeRequired},
+ {sfDestination, SoeOptional},
+ {sfExpiration, SoeOptional},
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+}))
+
+/** A ledger object which describes a check.
+
+ \sa keylet::check
+ */
+LEDGER_ENTRY(ltCHECK, 0x0043, Check, check, ({
+ {sfAccount, SoeRequired},
+ {sfDestination, SoeRequired},
+ {sfSendMax, SoeRequired},
+ {sfSequence, SoeRequired},
+ {sfOwnerNode, SoeRequired},
+ {sfDestinationNode, SoeRequired},
+ {sfExpiration, SoeOptional},
+ {sfInvoiceID, SoeOptional},
+ {sfSourceTag, SoeOptional},
+ {sfDestinationTag, SoeOptional},
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+}))
+
+/** The ledger object which tracks the DID.
+
+ \sa keylet::did
+*/
+LEDGER_ENTRY(ltDID, 0x0049, DID, did, ({
+ {sfAccount, SoeRequired},
+ {sfDIDDocument, SoeOptional},
+ {sfURI, SoeOptional},
+ {sfData, SoeOptional},
+ {sfOwnerNode, SoeRequired},
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+}))
+
+/** The ledger object which tracks the current negative UNL state.
+
+ \note This is a singleton: only one such object exists in the ledger.
+
+ \sa keylet::negativeUNL
+ */
+LEDGER_ENTRY(ltNEGATIVE_UNL, 0x004e, NegativeUNL, nunl, ({
+ {sfDisabledValidators, SoeOptional},
+ {sfValidatorToDisable, SoeOptional},
+ {sfValidatorToReEnable, SoeOptional},
+ {sfPreviousTxnID, SoeOptional},
+ {sfPreviousTxnLgrSeq, SoeOptional},
+}))
+
+/** A ledger object which contains a list of NFTs
+
+ \sa keylet::nftokenPageMin, keylet::nftokenPageMax, keylet::nftokenPage
+ */
+LEDGER_ENTRY(ltNFTOKEN_PAGE, 0x0050, NFTokenPage, nft_page, ({
+ {sfPreviousPageMin, SoeOptional},
+ {sfNextPageMin, SoeOptional},
+ {sfNFTokens, SoeRequired},
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+}))
+
+/** A ledger object which contains a signer list for an account.
+
+ \sa keylet::signerList
+ */
+// All fields are SoeRequired because there is always a SignerEntries.
+// If there are no SignerEntries the node is deleted.
+LEDGER_ENTRY(ltSIGNER_LIST, 0x0053, SignerList, signer_list, ({
+ {sfOwner, SoeOptional},
+ {sfOwnerNode, SoeRequired},
+ {sfSignerQuorum, SoeRequired},
+ {sfSignerEntries, SoeRequired},
+ {sfSignerListID, SoeRequired},
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+}))
+
+/** A ledger object which describes a ticket.
+
+ \sa keylet::ticket
+ */
+LEDGER_ENTRY(ltTICKET, 0x0054, Ticket, ticket, ({
+ {sfAccount, SoeRequired},
+ {sfOwnerNode, SoeRequired},
+ {sfTicketSequence, SoeRequired},
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+}))
+
+/** A ledger object which describes an account.
+
+ \sa keylet::account
+ */
+LEDGER_ENTRY(ltACCOUNT_ROOT, 0x0061, AccountRoot, account, ({
+ {sfAccount, SoeRequired},
+ {sfSequence, SoeRequired},
+ {sfBalance, SoeRequired},
+ {sfOwnerCount, SoeRequired},
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+ {sfAccountTxnID, SoeOptional},
+ {sfRegularKey, SoeOptional},
+ {sfEmailHash, SoeOptional},
+ {sfWalletLocator, SoeOptional},
+ {sfWalletSize, SoeOptional},
+ {sfMessageKey, SoeOptional},
+ {sfTransferRate, SoeOptional},
+ {sfDomain, SoeOptional},
+ {sfTickSize, SoeOptional},
+ {sfTicketCount, SoeOptional},
+ {sfNFTokenMinter, SoeOptional},
+ {sfMintedNFTokens, SoeDefault},
+ {sfBurnedNFTokens, SoeDefault},
+ {sfFirstNFTokenSequence, SoeOptional},
+ {sfSponsoredOwnerCount, SoeDefault},
+ {sfSponsoringOwnerCount, SoeDefault},
+ {sfSponsoringAccountCount, SoeDefault},
+ {sfAMMID, SoeOptional}, // pseudo-account designator
+ {sfVaultID, SoeOptional}, // pseudo-account designator
+ {sfLoanBrokerID, SoeOptional}, // pseudo-account designator
+}))
+
+/** A ledger object which contains a list of object identifiers.
+
+ \sa keylet::page, keylet::quality, keylet::book, keylet::next and
+ keylet::ownerDir
+ */
+LEDGER_ENTRY(ltDIR_NODE, 0x0064, DirectoryNode, directory, ({
+ {sfOwner, SoeOptional}, // for owner directories
+ {sfTakerPaysCurrency, SoeOptional}, // order book directories
+ {sfTakerPaysIssuer, SoeOptional}, // order book directories
+ {sfTakerPaysMPT, SoeOptional}, // order book directories
+ {sfTakerGetsCurrency, SoeOptional}, // order book directories
+ {sfTakerGetsIssuer, SoeOptional}, // order book directories
+ {sfTakerGetsMPT, SoeOptional}, // order book directories
+ {sfExchangeRate, SoeOptional}, // order book directories
+ {sfIndexes, SoeRequired},
+ {sfRootIndex, SoeRequired},
+ {sfIndexNext, SoeOptional},
+ {sfIndexPrevious, SoeOptional},
+ {sfNFTokenID, SoeOptional},
+ {sfPreviousTxnID, SoeOptional},
+ {sfPreviousTxnLgrSeq, SoeOptional},
+ {sfDomainID, SoeOptional} // order book directories
+}))
+
+/** The ledger object which lists details about amendments on the network.
+
+ \note This is a singleton: only one such object exists in the ledger.
+
+ \sa keylet::amendments
+ */
+LEDGER_ENTRY(ltAMENDMENTS, 0x0066, Amendments, amendments, ({
+ {sfAmendments, SoeOptional}, // Enabled
+ {sfMajorities, SoeOptional},
+ {sfPreviousTxnID, SoeOptional},
+ {sfPreviousTxnLgrSeq, SoeOptional},
+}))
+
+/** A ledger object that contains a list of ledger hashes.
+
+ This type is used to store the ledger hashes which the protocol uses
+ to implement skip lists that allow for efficient backwards (and, in
+ theory, forward) forward iteration across large ledger ranges.
+
+ \sa keylet::skip
+ */
+LEDGER_ENTRY(ltLEDGER_HASHES, 0x0068, LedgerHashes, hashes, ({
+ {sfFirstLedgerSequence, SoeOptional},
+ {sfLastLedgerSequence, SoeOptional},
+ {sfHashes, SoeRequired},
+}))
+
+/** The ledger object which lists details about sidechains.
+
+ \sa keylet::bridge
+*/
+LEDGER_ENTRY(ltBRIDGE, 0x0069, Bridge, bridge, ({
+ {sfAccount, SoeRequired},
+ {sfSignatureReward, SoeRequired},
+ {sfMinAccountCreateAmount, SoeOptional},
+ {sfXChainBridge, SoeRequired},
+ {sfXChainClaimID, SoeRequired},
+ {sfXChainAccountCreateCount, SoeRequired},
+ {sfXChainAccountClaimCount, SoeRequired},
+ {sfOwnerNode, SoeRequired},
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+}))
+
+/** A ledger object which describes an offer on the DEX.
+
+ \sa keylet::offer
+ */
+LEDGER_ENTRY(ltOFFER, 0x006f, Offer, offer, ({
+ {sfAccount, SoeRequired},
+ {sfSequence, SoeRequired},
+ {sfTakerPays, SoeRequired},
+ {sfTakerGets, SoeRequired},
+ {sfBookDirectory, SoeRequired},
+ {sfBookNode, SoeRequired},
+ {sfOwnerNode, SoeRequired},
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+ {sfExpiration, SoeOptional},
+ {sfDomainID, SoeOptional},
+ {sfAdditionalBooks, SoeOptional},
+}))
+
+/** A ledger object which describes a deposit pre-authorization.
+
+ \sa keylet::depositPreauth
+ */
+LEDGER_ENTRY_DUPLICATE(ltDEPOSIT_PREAUTH, 0x0070, DepositPreauth, deposit_preauth, ({
+ {sfAccount, SoeRequired},
+ {sfAuthorize, SoeOptional},
+ {sfOwnerNode, SoeRequired},
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+ {sfAuthorizeCredentials, SoeOptional},
+}))
+
+/** A claim id for a cross chain transaction.
+
+ \sa keylet::xChainClaimID
+*/
+LEDGER_ENTRY(ltXCHAIN_OWNED_CLAIM_ID, 0x0071, XChainOwnedClaimID, xchain_owned_claim_id, ({
+ {sfAccount, SoeRequired},
+ {sfXChainBridge, SoeRequired},
+ {sfXChainClaimID, SoeRequired},
+ {sfOtherChainSource, SoeRequired},
+ {sfXChainClaimAttestations, SoeRequired},
+ {sfSignatureReward, SoeRequired},
+ {sfOwnerNode, SoeRequired},
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+}))
+
+/** A ledger object which describes a bidirectional trust line.
+
+ @note Per Vinnie Falco this should be renamed to ltTRUST_LINE
+
+ \sa keylet::trustLine
+ */
+LEDGER_ENTRY(ltRIPPLE_STATE, 0x0072, RippleState, state, ({
+ {sfBalance, SoeRequired},
+ {sfLowLimit, SoeRequired},
+ {sfHighLimit, SoeRequired},
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+ {sfLowNode, SoeOptional},
+ {sfLowQualityIn, SoeOptional},
+ {sfLowQualityOut, SoeOptional},
+ {sfHighNode, SoeOptional},
+ {sfHighQualityIn, SoeOptional},
+ {sfHighQualityOut, SoeOptional},
+ {sfHighSponsor, SoeOptional},
+ {sfLowSponsor, SoeOptional},
+}))
+
+/** The ledger object which lists the network's fee settings.
+
+ \note This is a singleton: only one such object exists in the ledger.
+
+ \sa keylet::feeSettings
+ */
+LEDGER_ENTRY(ltFEE_SETTINGS, 0x0073, FeeSettings, fee, ({
+ // Old version uses raw numbers
+ {sfBaseFee, SoeOptional},
+ {sfReferenceFeeUnits, SoeOptional},
+ {sfReserveBase, SoeOptional},
+ {sfReserveIncrement, SoeOptional},
+ // New version uses Amounts
+ {sfBaseFeeDrops, SoeOptional},
+ {sfReserveBaseDrops, SoeOptional},
+ {sfReserveIncrementDrops, SoeOptional},
+ {sfPreviousTxnID, SoeOptional},
+ {sfPreviousTxnLgrSeq, SoeOptional},
+}))
+
+/** A claim id for a cross chain create account transaction.
+
+ \sa keylet::xChainCreateAccountClaimID
+*/
+LEDGER_ENTRY(ltXCHAIN_OWNED_CREATE_ACCOUNT_CLAIM_ID, 0x0074, XChainOwnedCreateAccountClaimID, xchain_owned_create_account_claim_id, ({
+ {sfAccount, SoeRequired},
+ {sfXChainBridge, SoeRequired},
+ {sfXChainAccountCreateCount, SoeRequired},
+ {sfXChainCreateAccountAttestations, SoeRequired},
+ {sfOwnerNode, SoeRequired},
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+}))
+
+/** A ledger object describing a single escrow.
+
+ \sa keylet::escrow
+ */
+LEDGER_ENTRY(ltESCROW, 0x0075, Escrow, escrow, ({
+ {sfAccount, SoeRequired},
+ {sfSequence, SoeOptional},
+ {sfDestination, SoeRequired},
+ {sfAmount, SoeRequired},
+ {sfCondition, SoeOptional},
+ {sfCancelAfter, SoeOptional},
+ {sfFinishAfter, SoeOptional},
+ {sfSourceTag, SoeOptional},
+ {sfDestinationTag, SoeOptional},
+ {sfOwnerNode, SoeRequired},
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+ {sfDestinationNode, SoeOptional},
+ {sfTransferRate, SoeOptional},
+ {sfIssuerNode, SoeOptional},
+}))
+
+/** A ledger object describing a single unidirectional XRP payment channel.
+
+ \sa keylet::payChannel
+ */
+LEDGER_ENTRY(ltPAYCHAN, 0x0078, PayChannel, payment_channel, ({
+ {sfAccount, SoeRequired},
+ {sfDestination, SoeRequired},
+ {sfSequence, SoeOptional},
+ {sfAmount, SoeRequired},
+ {sfBalance, SoeRequired},
+ {sfPublicKey, SoeRequired},
+ {sfSettleDelay, SoeRequired},
+ {sfExpiration, SoeOptional},
+ {sfCancelAfter, SoeOptional},
+ {sfSourceTag, SoeOptional},
+ {sfDestinationTag, SoeOptional},
+ {sfOwnerNode, SoeRequired},
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+ {sfDestinationNode, SoeOptional},
+}))
+
+/** The ledger object which tracks the AMM.
+
+ \sa keylet::amm
+*/
+LEDGER_ENTRY(ltAMM, 0x0079, AMM, amm, ({
+ {sfAccount, SoeRequired},
+ {sfTradingFee, SoeDefault},
+ {sfVoteSlots, SoeOptional},
+ {sfAuctionSlot, SoeOptional},
+ {sfLPTokenBalance, SoeRequired},
+ {sfAsset, SoeRequired},
+ {sfAsset2, SoeRequired},
+ {sfOwnerNode, SoeRequired},
+ {sfPreviousTxnID, SoeOptional},
+ {sfPreviousTxnLgrSeq, SoeOptional},
+}))
+
+/** A ledger object which tracks MPTokenIssuance
+ \sa keylet::mptokenIssuance
+ */
+LEDGER_ENTRY(ltMPTOKEN_ISSUANCE, 0x007e, MPTokenIssuance, mpt_issuance, ({
+ {sfIssuer, SoeRequired},
+ {sfSequence, SoeRequired},
+ {sfTransferFee, SoeDefault},
+ {sfOwnerNode, SoeRequired},
+ {sfAssetScale, SoeDefault},
+ {sfMaximumAmount, SoeOptional},
+ {sfOutstandingAmount, SoeRequired},
+ {sfLockedAmount, SoeOptional},
+ {sfMPTokenMetadata, SoeOptional},
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+ {sfDomainID, SoeOptional},
+ {sfMutableFlags, SoeDefault},
+ {sfReferenceHolding, SoeOptional},
+ {sfIssuerEncryptionKey, SoeOptional},
+ {sfAuditorEncryptionKey, SoeOptional},
+ {sfConfidentialOutstandingAmount, SoeDefault},
+}))
+
+/** A ledger object which tracks MPToken
+ \sa keylet::mptoken
+ */
+LEDGER_ENTRY(ltMPTOKEN, 0x007f, MPToken, mptoken, ({
+ {sfAccount, SoeRequired},
+ {sfMPTokenIssuanceID, SoeRequired},
+ {sfMPTAmount, SoeDefault},
+ {sfLockedAmount, SoeOptional},
+ {sfOwnerNode, SoeRequired},
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+ {sfConfidentialBalanceInbox, SoeOptional},
+ {sfConfidentialBalanceSpending, SoeOptional},
+ {sfConfidentialBalanceVersion, SoeDefault},
+ {sfIssuerEncryptedBalance, SoeOptional},
+ {sfAuditorEncryptedBalance, SoeOptional},
+ {sfHolderEncryptionKey, SoeOptional},
+}))
+
+/** A ledger object which tracks Oracle
+ \sa keylet::oracle
+ */
+LEDGER_ENTRY(ltORACLE, 0x0080, Oracle, oracle, ({
+ {sfOwner, SoeRequired},
+ {sfOracleDocumentID, SoeOptional},
+ {sfProvider, SoeRequired},
+ {sfPriceDataSeries, SoeRequired},
+ {sfAssetClass, SoeRequired},
+ {sfLastUpdateTime, SoeRequired},
+ {sfURI, SoeOptional},
+ {sfOwnerNode, SoeRequired},
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+}))
+
+/** A ledger object which tracks Credential
+ \sa keylet::credential
+ */
+LEDGER_ENTRY(ltCREDENTIAL, 0x0081, Credential, credential, ({
+ {sfSubject, SoeRequired},
+ {sfIssuer, SoeRequired},
+ {sfCredentialType, SoeRequired},
+ {sfExpiration, SoeOptional},
+ {sfURI, SoeOptional},
+ {sfIssuerNode, SoeRequired},
+ {sfSubjectNode, SoeOptional},
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+}))
+
+/** A ledger object which tracks PermissionedDomain
+ \sa keylet::permissionedDomain
+ */
+LEDGER_ENTRY(ltPERMISSIONED_DOMAIN, 0x0082, PermissionedDomain, permissioned_domain, ({
+ {sfOwner, SoeRequired},
+ {sfSequence, SoeRequired},
+ {sfAcceptedCredentials, SoeRequired},
+ {sfOwnerNode, SoeRequired},
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+}))
+
+/** A ledger object representing permissions an account has delegated to another account.
+ \sa keylet::delegate
+ */
+LEDGER_ENTRY(ltDELEGATE, 0x0083, Delegate, delegate, ({
+ {sfAccount, SoeRequired},
+ {sfAuthorize, SoeRequired},
+ {sfPermissions, SoeRequired},
+ {sfOwnerNode, SoeRequired},
+ {sfDestinationNode, SoeOptional},
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+}))
+
+/** A ledger object representing a single asset vault.
+ \sa keylet::vault
+ */
+LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+ {sfSequence, SoeRequired},
+ {sfOwnerNode, SoeRequired},
+ {sfOwner, SoeRequired},
+ {sfAccount, SoeRequired},
+ {sfData, SoeOptional},
+ {sfAsset, SoeRequired},
+ {sfAssetsTotal, SoeDefault},
+ {sfAssetsAvailable, SoeDefault},
+ {sfAssetsMaximum, SoeDefault},
+ {sfLossUnrealized, SoeDefault},
+ {sfShareMPTID, SoeRequired},
+ {sfWithdrawalPolicy, SoeRequired},
+ {sfScale, SoeDefault},
+ {sfLEVersion, SoeDefault},
+ // no SharesTotal ever (use MPTIssuance.sfOutstandingAmount)
+ // no PermissionedDomainID ever (use MPTIssuance.sfDomainID)
+}))
+
+/** Reserve 0x0084-0x0087 for future Vault-related objects. */
+
+/** A ledger object representing a loan broker
+
+ \sa keylet::loanBroker
+ */
+LEDGER_ENTRY(ltLOAN_BROKER, 0x0088, LoanBroker, loan_broker, ({
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+ {sfSequence, SoeRequired},
+ {sfOwnerNode, SoeRequired},
+ {sfVaultNode, SoeRequired},
+ {sfVaultID, SoeRequired},
+ {sfAccount, SoeRequired},
+ {sfOwner, SoeRequired},
+ {sfLoanSequence, SoeRequired},
+ {sfData, SoeDefault},
+ {sfManagementFeeRate, SoeDefault},
+ {sfOwnerCount, SoeDefault},
+ {sfDebtTotal, SoeDefault},
+ {sfDebtMaximum, SoeDefault},
+ {sfCoverAvailable, SoeDefault},
+ {sfCoverRateMinimum, SoeDefault},
+ {sfCoverRateLiquidation, SoeDefault},
+}))
+
+/** A ledger object representing a loan between a Borrower and a Loan Broker
+
+ \sa keylet::loan
+ */
+LEDGER_ENTRY(ltLOAN, 0x0089, Loan, loan, ({
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+ {sfOwnerNode, SoeRequired},
+ {sfLoanBrokerNode, SoeRequired},
+ {sfLoanBrokerID, SoeRequired},
+ {sfLoanSequence, SoeRequired},
+ {sfBorrower, SoeRequired},
+ {sfLoanOriginationFee, SoeDefault},
+ {sfLoanServiceFee, SoeDefault},
+ {sfLatePaymentFee, SoeDefault},
+ {sfClosePaymentFee, SoeDefault},
+ {sfOverpaymentFee, SoeDefault},
+ {sfInterestRate, SoeDefault},
+ {sfLateInterestRate, SoeDefault},
+ {sfCloseInterestRate, SoeDefault},
+ {sfOverpaymentInterestRate, SoeDefault},
+ {sfStartDate, SoeRequired},
+ {sfPaymentInterval, SoeRequired},
+ {sfGracePeriod, SoeDefault},
+ {sfPreviousPaymentDueDate, SoeDefault},
+ {sfNextPaymentDueDate, SoeDefault},
+ // The loan object tracks these values:
+ //
+ // - PaymentRemaining: The number of payments left in the loan. When it
+ // reaches 0, the loan is paid off, and all other relevant values
+ // must also be 0.
+ //
+ // - PeriodicPayment: The fixed, unrounded amount to be paid each
+ // interval. Stored with as much precision as possible.
+ // Payment transactions must round this value *UP*.
+ //
+ // - TotalValueOutstanding: The rounded total amount owed by the
+ // borrower to the lender / vault.
+ //
+ // - PrincipalOutstanding: The rounded portion of the
+ // TotalValueOutstanding that is from the principal borrowed.
+ //
+ // - ManagementFeeOutstanding: The rounded portion of the
+ // TotalValueOutstanding that represents management fees
+ // specifically owed to the broker based on the initial
+ // loan parameters.
+ //
+ // There are additional values that can be computed from these:
+ //
+ // - InterestOutstanding = TotalValueOutstanding - PrincipalOutstanding
+ // The total amount of interest still pending on the loan,
+ // independent of management fees.
+ //
+ // - InterestOwedToVault = InterestOutstanding - ManagementFeeOutstanding
+ // The amount of the total interest that is owed to the vault, and
+ // will be sent to it as part of a payment.
+ //
+ // - TrueTotalLoanValue = PaymentRemaining * PeriodicPayment
+ // The unrounded true total value of the loan.
+ //
+ // - TrueTotalPrincipalOutstanding can be computed using the algorithm
+ // in the xrpl::detail::loanPrincipalFromPeriodicPayment function.
+ //
+ // - TrueTotalInterestOutstanding = TrueTotalLoanValue -
+ // TrueTotalPrincipalOutstanding
+ // The unrounded true total interest remaining.
+ //
+ // - TrueTotalManagementFeeOutstanding = TrueTotalInterestOutstanding *
+ // LoanBroker.ManagementFeeRate
+ // The unrounded true total fee still owed to the broker.
+ //
+ // Note the "True" values may differ significantly from the tracked
+ // rounded values.
+ {sfPaymentRemaining, SoeDefault},
+ {sfPeriodicPayment, SoeRequired},
+ {sfPrincipalOutstanding, SoeDefault},
+ {sfTotalValueOutstanding, SoeDefault},
+ {sfManagementFeeOutstanding, SoeDefault},
+ // Based on the computed total value at creation, used for
+ // rounding calculated values so they are all on a
+ // consistent scale - that is, they all have the same
+ // number of digits after the decimal point (excluding
+ // trailing zeros).
+ {sfLoanScale, SoeDefault},
+}))
+
+/** A ledger object representing a sponsorship.
+ \sa keylet::sponsorship
+ */
+LEDGER_ENTRY(ltSPONSORSHIP, 0x0090, Sponsorship, sponsorship, ({
+ {sfPreviousTxnID, SoeRequired},
+ {sfPreviousTxnLgrSeq, SoeRequired},
+ {sfOwner, SoeRequired},
+ {sfSponsee, SoeRequired},
+ {sfFeeAmount, SoeOptional},
+ {sfMaxFee, SoeOptional},
+ {sfRemainingOwnerCount, SoeDefault},
+ {sfOwnerNode, SoeRequired},
+ {sfSponseeNode, SoeRequired},
+}))
+
+#undef EXPAND
+#undef LEDGER_ENTRY_DUPLICATE
diff --git a/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro.ref b/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro.ref
new file mode 100644
index 00000000..c88bbc16
--- /dev/null
+++ b/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro.ref
@@ -0,0 +1,23 @@
+https://github.com/XRPLF/rippled/blob/develop/include/xrpl/protocol/detail/ledger_entries.macro
+sha ecdd457f3598c7286a9af4aff358fbd30039173f
+date 2026-07-30T23:04:38Z
+
+ledger_entries.macro is vendored byte-identical to the ref above so that it can be
+re-verified with a plain diff:
+
+ curl -sSL https://raw.githubusercontent.com/XRPLF/rippled/ecdd457f3598c7286a9af4aff358fbd30039173f/include/xrpl/protocol/detail/ledger_entries.macro \
+ | diff - Tests/Xrpl.Tests/Fixtures/ledger_entries.macro
+
+This is the only place the protocol states which fields belong to which ledger
+object: definitions.json carries field codes and object types, but not the
+per-object field lists.
+
+Pinned to a develop commit rather than to a release tag — unlike LedgerFormats.h,
+which is pinned to 3.3.0-rc1 because the two are identical there. The models track
+develop for fields: sfLEVersion (Vault) exists only after 07/30/2026 and is absent
+from 3.3.0-rc1, so a tag would report it as a field the models invented. This sha
+is the one protocol-watch recorded when it reported the change.
+
+Do not hand-edit it. When protocol-watch reports a change to this file upstream,
+replace it wholesale, update the sha above, and let TestULedgerEntryFieldsConformance
+show which models have to follow.
diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestILoan.cs b/Tests/Xrpl.Tests/Integration/transactions/TestILoan.cs
index 8544dc8d..63f57c37 100644
--- a/Tests/Xrpl.Tests/Integration/transactions/TestILoan.cs
+++ b/Tests/Xrpl.Tests/Integration/transactions/TestILoan.cs
@@ -336,11 +336,9 @@ public async Task TestLoanLedgerEntry_VerifyFields()
Assert.IsNotNull(loan.LoanBrokerID, "LoanBrokerID should be set");
Assert.IsNotNull(loan.LoanSequence, "LoanSequence should be set");
- // Number fields — PrincipalRequested was explicitly set to "10000000" in LoanSet,
- // but rippled may omit zero-value Number fields.
- // PrincipalOutstanding may be null if no payments have been made yet (depends on rippled behavior).
- if (loan.PrincipalRequested != null)
- Assert.IsTrue(loan.PrincipalRequested.Length > 0, "PrincipalRequested should be non-empty if present");
+ // PrincipalRequested is a field of the LoanSet TRANSACTION, not of the Loan object:
+ // rippled records the amount as PrincipalOutstanding, so the object never carries it
+ // (confirmed against a live node — the created object holds PrincipalOutstanding only).
if (loan.PrincipalOutstanding != null)
Assert.IsTrue(loan.PrincipalOutstanding.Length > 0, "PrincipalOutstanding should be non-empty if present");
diff --git a/Tests/Xrpl.Tests/Models/RippledLedgerEntryFormats.cs b/Tests/Xrpl.Tests/Models/RippledLedgerEntryFormats.cs
new file mode 100644
index 00000000..ec4b9ee8
--- /dev/null
+++ b/Tests/Xrpl.Tests/Models/RippledLedgerEntryFormats.cs
@@ -0,0 +1,157 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text.RegularExpressions;
+
+namespace Xrpl.Tests.Models.Tests
+{
+ ///
+ /// Reads the vendored rippled ledger_entries.macro — the only place the protocol
+ /// states which fields belong to which ledger object. definitions.json carries field
+ /// codes and object types, but not the per-object field lists, so it cannot answer this.
+ ///
+ ///
+ /// The counterpart of for ledger objects. Same
+ /// contract: the source is C++ macro text, so every parse step fails loudly rather than
+ /// yielding a thin or empty table — a silently empty result would turn the conformance
+ /// test green on nothing.
+ ///
+ internal static class RippledLedgerEntryFormats
+ {
+ ///
+ /// LEDGER_ENTRY(ltTAG, 0x00NN, Name, rpcName, ({ {sfField, SoeX}, ... }))
+ /// LEDGER_ENTRY_DUPLICATE(...) has the same shape and declares an object that shares
+ /// a type code with another one, so it is parsed identically.
+ ///
+ private static readonly Regex EntryBlock = new Regex(
+ @"LEDGER_ENTRY(?:_DUPLICATE)?\(\s*lt\w+\s*,\s*0x[0-9a-fA-F]+\s*,\s*(?\w+)\s*,(?.*?)\}\)\)",
+ RegexOptions.Singleline | RegexOptions.Compiled);
+
+ /// {sfField, SoeRequired} / {sfField, SoeOptional} / {sfField, SoeDefault}
+ private static readonly Regex FieldEntry = new Regex(
+ @"\{\s*sf(?\w+)\s*,\s*Soe(?Required|Optional|Default)\b",
+ RegexOptions.Compiled);
+
+ /// Catches a requirement keyword the mapping below does not know yet.
+ private static readonly Regex AnyFieldEntry = new Regex(
+ @"\{\s*sf(?\w+)\s*,\s*Soe(?\w+)",
+ RegexOptions.Compiled);
+
+ ///
+ /// Lower bounds on a healthy parse, asserted by the guard test as well so the two
+ /// cannot disagree about what "parsed enough" means.
+ ///
+ internal const int MinimumExpectedEntries = 25;
+
+ ///
+ internal const int MinimumExpectedFields = 250;
+
+ internal static string FixturePath =>
+ Path.Combine(AppContext.BaseDirectory, "Fixtures", "ledger_entries.macro");
+
+ ///
+ /// Fields every ledger object carries, declared once in rippled's
+ /// LedgerFormats::getCommonFields() (src/libxrpl/protocol/LedgerFormats.cpp)
+ /// rather than per object in the macro — the ledger-side counterpart of
+ /// TxFormats' commonFields. Both directions of the conformance diff
+ /// exclude them: the macro never lists them, so requiring them of a model would be
+ /// wrong, and a model that does expose them is not inventing anything.
+ ///
+ ///
+ /// Hand-maintained: the list lives in a .cpp, which protocol-watch does not track
+ /// (it watches the headers and macros). It has held these four for releases —
+ /// sfSponsor was the last addition, with XLS-68 — so drift here is slow and
+ /// visible: a new common field would surface as the same name reported missing from
+ /// every single model at once.
+ ///
+ internal static HashSet CommonFields() => new(StringComparer.Ordinal)
+ {
+ "LedgerIndex",
+ "LedgerEntryType",
+ "Flags",
+ "Sponsor",
+ };
+
+ /// How rippled declares a field of a ledger object.
+ internal enum Requirement
+ {
+ /// Always present.
+ Required,
+
+ /// May be absent.
+ Optional,
+
+ /// Absent means the type's default value, not missing data.
+ Default,
+ }
+
+ ///
+ /// Ledger object name -> field name -> requirement, exactly as rippled declares it.
+ ///
+ internal static Dictionary> Parse()
+ {
+ if (!File.Exists(FixturePath))
+ throw new InvalidOperationException($"Vendored ledger_entries.macro not found at {FixturePath}");
+
+ string macro = File.ReadAllText(FixturePath);
+ if (string.IsNullOrWhiteSpace(macro))
+ throw new InvalidOperationException("Vendored ledger_entries.macro is empty");
+
+ Dictionary> entries = new();
+ int fieldCount = 0;
+
+ foreach (Match block in EntryBlock.Matches(macro))
+ {
+ string name = block.Groups["name"].Value;
+ string body = block.Groups["body"].Value;
+
+ foreach (Match raw in AnyFieldEntry.Matches(body))
+ {
+ string keyword = raw.Groups["requirement"].Value;
+ if (keyword is not ("Required" or "Optional" or "Default"))
+ {
+ throw new InvalidOperationException(
+ $"{name}.{raw.Groups["field"].Value}: unknown requirement keyword 'Soe{keyword}' — " +
+ "the macro format changed, update the parser before trusting this test");
+ }
+ }
+
+ Dictionary fields = new();
+ foreach (Match field in FieldEntry.Matches(body))
+ {
+ fields[field.Groups["field"].Value] = field.Groups["requirement"].Value switch
+ {
+ "Required" => Requirement.Required,
+ "Optional" => Requirement.Optional,
+ "Default" => Requirement.Default,
+ _ => throw new InvalidOperationException("unreachable"),
+ };
+ }
+
+ // Indexer assignment would let a second declaration of the same name replace
+ // the first, dropping that object from the conformance table while the field
+ // count below still grew — the minimum-count guard would not notice
+ if (entries.ContainsKey(name))
+ {
+ throw new InvalidOperationException(
+ $"{name}: declared twice in ledger_entries.macro — the parser would drop one " +
+ "definition, update it before trusting this test");
+ }
+
+ entries.Add(name, fields);
+ fieldCount += fields.Count;
+ }
+
+ if (entries.Count < MinimumExpectedEntries || fieldCount < MinimumExpectedFields)
+ {
+ throw new InvalidOperationException(
+ $"Parsed only {entries.Count} ledger entries / {fieldCount} fields from " +
+ $"ledger_entries.macro (expected at least {MinimumExpectedEntries} / " +
+ $"{MinimumExpectedFields}) — the macro layout changed and the parser " +
+ "silently stopped matching");
+ }
+
+ return entries;
+ }
+ }
+}
diff --git a/Tests/Xrpl.Tests/Models/TestULedgerEntryFieldsConformance.cs b/Tests/Xrpl.Tests/Models/TestULedgerEntryFieldsConformance.cs
new file mode 100644
index 00000000..74e675d0
--- /dev/null
+++ b/Tests/Xrpl.Tests/Models/TestULedgerEntryFieldsConformance.cs
@@ -0,0 +1,181 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Text;
+using System.Text.Json.Serialization;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+using Xrpl.Models.Ledger;
+
+using LONFTokenOffer = Xrpl.Models.Methods.LONFTokenOffer;
+using LONFTokenPage = Xrpl.Models.Methods.LONFTokenPage;
+
+namespace Xrpl.Tests.Models.Tests
+{
+ ///
+ /// Holds the ledger-object models to the field sets rippled declares in the vendored
+ /// ledger_entries.macro.
+ ///
+ ///
+ /// The third conformance surface, next to (transaction
+ /// fields) and (ledger flags). A field the protocol
+ /// declares and the model lacks produces no symptom: reading the object still succeeds and the
+ /// value is simply dropped, so it stays invisible until someone needs it. That is how
+ /// LOAccountRoot went without WalletLocator/WalletSize until a manual completeness pass, and
+ /// how sfLEVersion had to be spotted through a protocol-watch notification instead of a red test.
+ ///
+ [TestClass]
+ public class TestULedgerEntryFieldsConformance
+ {
+ ///
+ /// rippled LEDGER_ENTRY name -> the model that carries its fields. Every entry in the
+ /// fixture must appear here; a newly added ledger object fails the test rather than
+ /// being skipped silently.
+ ///
+ private static readonly Dictionary Models = new(StringComparer.Ordinal)
+ {
+ ["AccountRoot"] = typeof(LOAccountRoot),
+ ["AMM"] = typeof(LOAmm),
+ ["Amendments"] = typeof(LOAmendments),
+ ["Bridge"] = typeof(LOBridge),
+ ["Check"] = typeof(LOCheck),
+ ["Credential"] = typeof(LOCredential),
+ ["Delegate"] = typeof(LODelegate),
+ ["DepositPreauth"] = typeof(LODepositPreauth),
+ ["DID"] = typeof(LODID),
+ ["DirectoryNode"] = typeof(LODirectoryNode),
+ ["Escrow"] = typeof(LOEscrow),
+ ["FeeSettings"] = typeof(LOFeeSettings),
+ ["LedgerHashes"] = typeof(LOLedgerHashes),
+ ["Loan"] = typeof(LOLoan),
+ ["LoanBroker"] = typeof(LOLoanBroker),
+ ["MPToken"] = typeof(LOMPToken),
+ ["MPTokenIssuance"] = typeof(LOMPTokenIssuance),
+ ["NegativeUNL"] = typeof(LONegativeUNL),
+ ["NFTokenOffer"] = typeof(LONFTokenOffer),
+ ["NFTokenPage"] = typeof(LONFTokenPage),
+ ["Offer"] = typeof(LOOffer),
+ ["Oracle"] = typeof(LOOracle),
+ ["PayChannel"] = typeof(LOPayChannel),
+ ["PermissionedDomain"] = typeof(LOPermissionedDomain),
+ ["RippleState"] = typeof(LORippleState),
+ ["SignerList"] = typeof(LOSignerList),
+ ["Sponsorship"] = typeof(LOSponsorship),
+ ["Ticket"] = typeof(LOTicket),
+ ["Vault"] = typeof(LOVault),
+ ["XChainOwnedClaimID"] = typeof(LOXChainOwnedClaimID),
+ ["XChainOwnedCreateAccountClaimID"] = typeof(LOXChainOwnedCreateAccountClaimID),
+ };
+
+ ///
+ /// Names that appear on a model but are not fields of that ledger object, with the
+ /// reason each is legitimate. Anything else the reverse check reports is a real finding.
+ /// Common fields are handled separately, via
+ /// .
+ ///
+ private static readonly Dictionary KnownExtras = new(StringComparer.Ordinal)
+ {
+ // BaseLedgerEntry.Index, serialized as "index" — the object's own key. rippled
+ // returns it alongside the object (account_objects, ledger_entry) and it is not
+ // part of any object's template
+ ["index"] = "the entry's key, returned beside the object rather than inside it",
+ };
+
+ ///
+ /// The JSON name a property maps to: when
+ /// present, the property name otherwise. Properties marked
+ /// never reach the wire and are excluded — that is where the computed helpers live
+ /// (DataParsed, MPTokenMetadataRow, Metadata, …).
+ ///
+ private static Dictionary WireProperties(Type model)
+ {
+ Dictionary map = new(StringComparer.Ordinal);
+
+ foreach (PropertyInfo property in model.GetProperties(BindingFlags.Public | BindingFlags.Instance))
+ {
+ if (property.GetCustomAttribute() != null)
+ continue;
+
+ string name = property.GetCustomAttribute()?.Name ?? property.Name;
+ map[name] = property;
+ }
+
+ return map;
+ }
+
+ [TestMethod]
+ public void TestULedgerEntryModels_MatchRippledLedgerEntriesMacro()
+ {
+ Dictionary> upstream =
+ RippledLedgerEntryFormats.Parse();
+ HashSet common = RippledLedgerEntryFormats.CommonFields();
+ StringBuilder report = new StringBuilder();
+
+ foreach (KeyValuePair> entry
+ in upstream.OrderBy(e => e.Key, StringComparer.Ordinal))
+ {
+ if (!Models.TryGetValue(entry.Key, out Type model))
+ {
+ report.AppendLine(
+ $"{entry.Key}: declared in ledger_entries.macro but no model is registered for it — " +
+ "add the LO type and register it in Models");
+ continue;
+ }
+
+ Dictionary mine = WireProperties(model);
+
+ foreach (string field in entry.Value.Keys.OrderBy(f => f, StringComparer.Ordinal))
+ {
+ if (!mine.ContainsKey(field))
+ {
+ report.AppendLine(
+ $"{entry.Key}.{field} ({entry.Value[field]}): declared by rippled, " +
+ $"missing from {model.Name}");
+ }
+ }
+
+ foreach (string name in mine.Keys.OrderBy(n => n, StringComparer.Ordinal))
+ {
+ if (entry.Value.ContainsKey(name) || common.Contains(name) || KnownExtras.ContainsKey(name))
+ continue;
+
+ report.AppendLine(
+ $"{model.Name}.{name}: on the model, not a field of {entry.Key} in rippled");
+ }
+ }
+
+ Assert.AreEqual(
+ string.Empty,
+ report.ToString(),
+ $"Ledger-object models diverge from rippled ledger_entries.macro ({RippledLedgerEntryFormats.FixturePath}):\n" + report);
+ }
+
+ [TestMethod]
+ public void TestULedgerEntryFixture_ParsesFully()
+ {
+ Dictionary> upstream =
+ RippledLedgerEntryFormats.Parse();
+
+ Assert.IsTrue(
+ upstream.Count >= RippledLedgerEntryFormats.MinimumExpectedEntries,
+ $"Parsed {upstream.Count} ledger entries, expected at least {RippledLedgerEntryFormats.MinimumExpectedEntries}");
+
+ int fields = upstream.Sum(e => e.Value.Count);
+ Assert.IsTrue(
+ fields >= RippledLedgerEntryFormats.MinimumExpectedFields,
+ $"Parsed {fields} fields, expected at least {RippledLedgerEntryFormats.MinimumExpectedFields}");
+
+ // Counts alone would still pass on a parse that dropped requirements
+ Assert.AreEqual(
+ RippledLedgerEntryFormats.Requirement.Default,
+ upstream["Vault"]["LEVersion"],
+ "Vault.LEVersion should parse as SoeDefault");
+ Assert.AreEqual(
+ RippledLedgerEntryFormats.Requirement.Required,
+ upstream["Vault"]["Owner"],
+ "Vault.Owner should parse as SoeRequired");
+ }
+ }
+}
diff --git a/Tests/Xrpl.Tests/Xrpl.Tests.csproj b/Tests/Xrpl.Tests/Xrpl.Tests.csproj
index 39091527..72fd5fa9 100644
--- a/Tests/Xrpl.Tests/Xrpl.Tests.csproj
+++ b/Tests/Xrpl.Tests/Xrpl.Tests.csproj
@@ -36,6 +36,9 @@
PreserveNewest
+
+ PreserveNewest
+
diff --git a/Xrpl/Models/Ledger/LOAmendments.cs b/Xrpl/Models/Ledger/LOAmendments.cs
index 688c3f7a..7ee5ad8e 100644
--- a/Xrpl/Models/Ledger/LOAmendments.cs
+++ b/Xrpl/Models/Ledger/LOAmendments.cs
@@ -43,6 +43,18 @@ public LOAmendments()
/// No flags are defined for the Amendments object type, so this value is always 0.
///
public uint Flags { get; set; }
+
+ ///
+ /// The identifying hash of the transaction that most recently modified this object.
+ ///
+ [JsonPropertyName("PreviousTxnID")]
+ public string PreviousTxnID { get; set; }
+
+ ///
+ /// The index of the ledger that contains the transaction that most recently modified this object.
+ ///
+ [JsonPropertyName("PreviousTxnLgrSeq")]
+ public uint? PreviousTxnLgrSeq { get; set; }
}
public class Majority
diff --git a/Xrpl/Models/Ledger/LOAmm.cs b/Xrpl/Models/Ledger/LOAmm.cs
index bcb7ead1..67d3b284 100644
--- a/Xrpl/Models/Ledger/LOAmm.cs
+++ b/Xrpl/Models/Ledger/LOAmm.cs
@@ -12,11 +12,13 @@ public class LOAmm : BaseLedgerEntry
{
public LOAmm()
{
- LedgerEntryType = LedgerEntryType.AccountRoot;
+ LedgerEntryType = LedgerEntryType.AMM;
}
///
- /// The account that tracks the balance of LPTokens between the AMM instance via Trustline.
+ /// The special account that holds the AMM's assets and issues its LPTokens.
+ /// Serialized as Account, which is the name rippled gives this field.
///
+ [JsonPropertyName("Account")]
public string AMMAccount { get; set; }
///
/// Specifies one of the pool assets (XRP or token) of the AMM instance.
@@ -53,21 +55,22 @@ public LOAmm()
/// A list of vote objects, representing votes on the pool's trading fee..
///
public List VoteSlots { get; set; }
- ///
- /// The ledger index of the current in-progress ledger, which was used when
- /// retrieving this information.
- ///
- public int? LedgerCurrentIndex { get; set; }
- ///
- /// True if this data is from a validated ledger version;
- /// if omitted or set to false, this data is not final.
- ///
- public bool? Validated { get; set; }
-
/// Owner directory page hint (hex UInt64).
[JsonPropertyName("OwnerNode")]
public string OwnerNode { get; set; }
+
+ ///
+ /// The identifying hash of the transaction that most recently modified this object.
+ ///
+ [JsonPropertyName("PreviousTxnID")]
+ public string PreviousTxnID { get; set; }
+
+ ///
+ /// The index of the ledger that contains the transaction that most recently modified this object.
+ ///
+ [JsonPropertyName("PreviousTxnLgrSeq")]
+ public uint? PreviousTxnLgrSeq { get; set; }
}
public interface IAuthAccount
diff --git a/Xrpl/Models/Ledger/LOCredential.cs b/Xrpl/Models/Ledger/LOCredential.cs
index c7325f5c..1360741e 100644
--- a/Xrpl/Models/Ledger/LOCredential.cs
+++ b/Xrpl/Models/Ledger/LOCredential.cs
@@ -101,12 +101,6 @@ public string URI
[JsonPropertyName("Flags")]
public new uint Flags { get; set; }
- ///
- /// A hint indicating which page of the owner directory links to this entry.
- ///
- [JsonPropertyName("OwnerNode")]
- public string OwnerNode { get; set; }
-
///
/// A hint indicating which page of the subject's owner directory links to this entry.
///
diff --git a/Xrpl/Models/Ledger/LODirectoryNode.cs b/Xrpl/Models/Ledger/LODirectoryNode.cs
index e6e21c43..4999a9fd 100644
--- a/Xrpl/Models/Ledger/LODirectoryNode.cs
+++ b/Xrpl/Models/Ledger/LODirectoryNode.cs
@@ -102,5 +102,17 @@ public LODirectoryNode()
/// MPT order books: MPT issuance id on the TakerGets side.
[JsonPropertyName("TakerGetsMPT")]
public string TakerGetsMPT { get; set; }
+
+ ///
+ /// The identifying hash of the transaction that most recently modified this object.
+ ///
+ [JsonPropertyName("PreviousTxnID")]
+ public string PreviousTxnID { get; set; }
+
+ ///
+ /// The index of the ledger that contains the transaction that most recently modified this object.
+ ///
+ [JsonPropertyName("PreviousTxnLgrSeq")]
+ public uint? PreviousTxnLgrSeq { get; set; }
}
}
diff --git a/Xrpl/Models/Ledger/LOFeeSettings.cs b/Xrpl/Models/Ledger/LOFeeSettings.cs
index de6998aa..4b2fc02b 100644
--- a/Xrpl/Models/Ledger/LOFeeSettings.cs
+++ b/Xrpl/Models/Ledger/LOFeeSettings.cs
@@ -47,5 +47,17 @@ public LOFeeSettings()
/// XRPFees: owner reserve increment in drops.
[JsonPropertyName("ReserveIncrementDrops")]
public string ReserveIncrementDrops { get; set; }
+
+ ///
+ /// The identifying hash of the transaction that most recently modified this object.
+ ///
+ [JsonPropertyName("PreviousTxnID")]
+ public string PreviousTxnID { get; set; }
+
+ ///
+ /// The index of the ledger that contains the transaction that most recently modified this object.
+ ///
+ [JsonPropertyName("PreviousTxnLgrSeq")]
+ public uint? PreviousTxnLgrSeq { get; set; }
}
}
diff --git a/Xrpl/Models/Ledger/LOLoan.cs b/Xrpl/Models/Ledger/LOLoan.cs
index e4f1bf1d..3b3dafe8 100644
--- a/Xrpl/Models/Ledger/LOLoan.cs
+++ b/Xrpl/Models/Ledger/LOLoan.cs
@@ -101,12 +101,6 @@ public LOLoan()
[JsonPropertyName("PrincipalOutstanding")]
public string PrincipalOutstanding { get; init; }
- ///
- /// The principal amount originally requested (Number type, string representation).
- ///
- [JsonPropertyName("PrincipalRequested")]
- public string PrincipalRequested { get; init; }
-
///
/// The total amount owed including fees (Number type, string representation).
///
diff --git a/Xrpl/Models/Ledger/LONFTokenPage.cs b/Xrpl/Models/Ledger/LONFTokenPage.cs
index c6cea0b0..31a555aa 100644
--- a/Xrpl/Models/Ledger/LONFTokenPage.cs
+++ b/Xrpl/Models/Ledger/LONFTokenPage.cs
@@ -17,11 +17,6 @@ public LONFTokenPage()
}
[JsonConverter(typeof(NumberOrStringConverter))]
public string Flags { get; set; }
- ///
- /// The locator of the next page, if any. Details about this field and how it should be used are outlined below.
- ///
- public string NFTokenPage { get; set; }
-
///
/// The collection of NFToken objects contained in this NFTokenPage object.
/// This specification places an upper bound of 32 NFToken objects per page.
diff --git a/Xrpl/Models/Ledger/LONegativeUNL.cs b/Xrpl/Models/Ledger/LONegativeUNL.cs
index 96441a3b..bc7f8c92 100644
--- a/Xrpl/Models/Ledger/LONegativeUNL.cs
+++ b/Xrpl/Models/Ledger/LONegativeUNL.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Text.Json.Serialization;
namespace Xrpl.Models.Ledger
{
@@ -25,6 +26,18 @@ public LONegativeUNL()
/// The public key of a trusted validator in the Negative UNL that is scheduled to be re-enabled in the next flag ledger.
///
public string ValidatorToReEnable { get; set; }
+
+ ///
+ /// The identifying hash of the transaction that most recently modified this object.
+ ///
+ [JsonPropertyName("PreviousTxnID")]
+ public string PreviousTxnID { get; set; }
+
+ ///
+ /// The index of the ledger that contains the transaction that most recently modified this object.
+ ///
+ [JsonPropertyName("PreviousTxnLgrSeq")]
+ public uint? PreviousTxnLgrSeq { get; set; }
}
public interface IDisabledValidator
{
diff --git a/Xrpl/Models/Ledger/LOVault.cs b/Xrpl/Models/Ledger/LOVault.cs
index ae7b15be..d8594488 100644
--- a/Xrpl/Models/Ledger/LOVault.cs
+++ b/Xrpl/Models/Ledger/LOVault.cs
@@ -202,12 +202,6 @@ public string DataRaw
}
}
- ///
- /// The ID of a permissioned domain associated with the vault.
- ///
- [JsonPropertyName("DomainID")]
- public string DomainID { get; init; }
-
///
/// The transaction sequence number that created the vault.
///