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