diff --git a/Base/Xrpl.BinaryCodec/Binary/BinaryParser.cs b/Base/Xrpl.BinaryCodec/Binary/BinaryParser.cs
index 00c41556..8801aa9e 100644
--- a/Base/Xrpl.BinaryCodec/Binary/BinaryParser.cs
+++ b/Base/Xrpl.BinaryCodec/Binary/BinaryParser.cs
@@ -26,11 +26,10 @@ public abstract class BinaryParser
///
///
public int ReadOneInt() => ReadOne() & 0xFF;
- /// Consume the first n bytes of the BinaryParser
- /// n the number of bytes to skip
+ /// The next byte in the BinaryParser, without consuming it
public abstract byte Peek();
- /// todo
- /// n the number of bytes to skip
+ /// Consume the first n bytes of the BinaryParser
+ /// the number of bytes to skip
public abstract void Skip(int n);
/// read the byte from the BinaryParser by current cursor position
public abstract byte ReadOne();
diff --git a/Base/Xrpl.BinaryCodec/Types/AccountId.cs b/Base/Xrpl.BinaryCodec/Types/AccountId.cs
index cadcdfef..49c48490 100644
--- a/Base/Xrpl.BinaryCodec/Types/AccountId.cs
+++ b/Base/Xrpl.BinaryCodec/Types/AccountId.cs
@@ -90,9 +90,12 @@ public override string ToString()
public static readonly AccountId Zero = 0;
public static readonly AccountId Neutral = 1;
- /// create instance from binary parser
- /// parser
- ///
+ /// create instance from an account id in hex, or from a classic address
+ ///
+ /// 40 uppercase hex characters, or a base58 classic address. The hex matcher is
+ /// ^[A-F0-9]{40}$ with no ignore-case option, so lowercase hex is not recognised as
+ /// hex and falls through to the address decoder.
+ ///
public static AccountId FromValue(string value)
{
Regex rg = new Regex(HEX_REGEX);
diff --git a/Base/Xrpl.BinaryCodec/Types/Issue.cs b/Base/Xrpl.BinaryCodec/Types/Issue.cs
index 01b9957e..278f8d17 100644
--- a/Base/Xrpl.BinaryCodec/Types/Issue.cs
+++ b/Base/Xrpl.BinaryCodec/Types/Issue.cs
@@ -148,6 +148,12 @@ public static Issue FromJson(JsonNode token)
// MPT format: { "mpt_issuance_id": "..." }
if (obj.ContainsKey("mpt_issuance_id"))
{
+ // Counted like the two forms below, which have always done this. Without it the MPT
+ // form was the one shape of Issue where a member this codec does not know was
+ // dropped on the way into a blob or an id, with nothing said about it.
+ if (obj.Count != 1)
+ throw new InvalidJsonException("MPT Issue object must contain only 'mpt_issuance_id'.");
+
string mptId = obj["mpt_issuance_id"]?.GetValue();
if (mptId is null)
throw new InvalidJsonException("Issue mpt_issuance_id must be a string.");
diff --git a/Base/Xrpl.BinaryCodec/Types/PathSet.cs b/Base/Xrpl.BinaryCodec/Types/PathSet.cs
index 3108f78b..6af4ffe7 100644
--- a/Base/Xrpl.BinaryCodec/Types/PathSet.cs
+++ b/Base/Xrpl.BinaryCodec/Types/PathSet.cs
@@ -1,4 +1,6 @@
using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -65,8 +67,36 @@ public PathHop(AccountId account, AccountId issuer, Currency currency, Hash192 m
/// Deserialize Hot
/// json token
///
+ ///
+ /// The members a path step may carry.
+ ///
+ ///
+ /// type and type_hex are in the set although nothing here reads them: the
+ /// node sends type on every step of a ripple_path_find answer, and this SDK
+ /// emits it back out of , so a path taken from a response and
+ /// put into a payment carries it. The byte is synthesised from which of account, currency
+ /// and issuer are present, so the member is redundant rather than unknown - refusing it
+ /// would break the ordinary path-finding flow outright. type_hex has not been sent
+ /// since rippled 1.7.0 and is tolerated for old data.
+ ///
+ private static readonly HashSet PathStepMembers = new HashSet(StringComparer.Ordinal)
+ {
+ "account", "currency", "issuer", "mpt_issuance_id", "type", "type_hex",
+ };
+
public static PathHop FromJson(JsonNode json)
{
+ if (json is JsonObject stepObject)
+ {
+ foreach (KeyValuePair member in stepObject)
+ {
+ if (!PathStepMembers.Contains(member.Key))
+ {
+ throw new InvalidJsonException($"unknown path step property `{member.Key}`");
+ }
+ }
+ }
+
JsonNode mptIssuanceId = json["mpt_issuance_id"];
if (mptIssuanceId != null
&& (!(mptIssuanceId is JsonValue mptJv) || mptJv.GetValueKind() != JsonValueKind.String))
diff --git a/Base/Xrpl.BinaryCodec/Types/StObject.cs b/Base/Xrpl.BinaryCodec/Types/StObject.cs
index 1b57eba1..be67f1ba 100644
--- a/Base/Xrpl.BinaryCodec/Types/StObject.cs
+++ b/Base/Xrpl.BinaryCodec/Types/StObject.cs
@@ -41,6 +41,11 @@ public BuildFrom(FromJson json, FromParser parser)
private static readonly Dictionary DispatchTable = new Dictionary
{
+ // The JSON halves of these two are no longer the route ParseObject takes - it walks
+ // objects and arrays itself, which is the only way the strictness flag reaches them.
+ // They stay because both are public API a caller can reach directly, and because
+ // FromParser still dispatches through here. Equivalent to ParseObject(node, false)
+ // today; keep them that way.
[FieldType.StObject] = new BuildFrom(FromJson, FromParser),
[FieldType.StArray] = new BuildFrom(StArray.FromJson, StArray.FromParser),
[FieldType.Uint8] = new BuildFrom(Uint8.FromJson, Uint8.FromParser),
@@ -143,10 +148,54 @@ public static StObject FromJson(JsonNode token)
/// Construct a STObject from a JSON object
///
/// An object to include
- /// optional, denote which field to include in serialized object
+ /// include only the fields that take part in signing
///
/// unknown field or token is not an object
public static StObject FromJson(JsonNode token, bool signingOnly)
+ {
+ StObject so = ParseObject(token, strict: signingOnly);
+ return signingOnly ? so.FilterIsSigning() : so;
+ }
+
+ ///
+ /// Builds an STObject from JSON, refusing members this codec does not know at any level,
+ /// and keeping every field it does know.
+ ///
+ ///
+ /// For callers that need the strictness without the signing filter - computing an id over
+ /// a transaction, where dropping the non-signing fields would hash something other than
+ /// the transaction. ties the two together because
+ /// one flag used to mean both.
+ ///
+ public static StObject FromJsonStrict(JsonNode token)
+ {
+ return ParseObject(token, strict: true);
+ }
+
+ ///
+ /// Builds an STObject from JSON, rejecting members this codec does not know when
+ /// - at every level, not just this one.
+ ///
+ ///
+ /// The recursion is the point. Nested objects used to reach this class through the
+ /// dispatch table, whose delegate signature carries no flag, so
+ /// they went through the lenient single-argument overload and an unknown member one level
+ /// down was dropped without a word. A caller could be shown a transaction containing a
+ /// member - a typo, or a field from an amendment newer than this SDK's definitions.json -
+ /// sign it, and put it in the ledger without that member. The top level failed loudly; one
+ /// level down it did not.
+ ///
+ /// rippled does the opposite: STParsedJSON::parseObject recurses and answers
+ /// unknownField at every level, so such a transaction does not parse at all.
+ ///
+ ///
+ /// Strictness and the signing filter are separate concerns, and only look alike because
+ /// one flag used to carry both. FilterIsSigning still applies to the top
+ /// level alone - dropping non-signing fields out of nested objects would change what gets
+ /// signed, which is not what this fixes.
+ ///
+ ///
+ private static StObject ParseObject(JsonNode token, bool strict)
{
if (!(token is JsonObject))
throw new InvalidJsonException($"{token.GetValueKind()} is not an object");
@@ -156,7 +205,7 @@ public static StObject FromJson(JsonNode token, bool signingOnly)
{
if (!Field.Values.Has(pair.Key))
{
- if (signingOnly)
+ if (strict)
throw new InvalidJsonException($"unknown field {pair.Key}");
continue;
}
@@ -165,7 +214,13 @@ public static StObject FromJson(JsonNode token, bool signingOnly)
ISerializedType st;
try
{
- st = fieldForType.FromJson(jsonForField);
+ // Objects and arrays are walked here rather than through the dispatch table,
+ // which is the only way the flag reaches them.
+ st = fieldForType.Type == FieldType.StObject
+ ? ParseObject(jsonForField, strict)
+ : fieldForType.Type == FieldType.StArray
+ ? ParseArray(jsonForField, strict)
+ : fieldForType.FromJson(jsonForField);
}
catch (Exception e) when (e is InvalidOperationException || e is FormatException || e is OverflowException || e is PrecisionException)
{
@@ -173,7 +228,20 @@ public static StObject FromJson(JsonNode token, bool signingOnly)
}
so.Fields[fieldForType] = st;
}
- return signingOnly ? so.FilterIsSigning() : so;
+ return so;
+ }
+
+ ///
+ /// Builds an STArray from JSON, carrying into every element.
+ ///
+ ///
+ /// Arrays are how the common case is reached: Memos, Signers and the rest
+ /// hold objects, so a member inside Memos[0].Memo is two levels down and was the
+ /// original report on this.
+ ///
+ private static StArray ParseArray(JsonNode token, bool strict)
+ {
+ return new StArray(token.AsArray().Select(n => ParseObject(n, strict)));
}
///
diff --git a/Base/Xrpl.BinaryCodec/Types/XChainBridgeType.cs b/Base/Xrpl.BinaryCodec/Types/XChainBridgeType.cs
index c0b46f9f..d15d6bb8 100644
--- a/Base/Xrpl.BinaryCodec/Types/XChainBridgeType.cs
+++ b/Base/Xrpl.BinaryCodec/Types/XChainBridgeType.cs
@@ -53,6 +53,24 @@ public static XChainBridgeType FromJson(JsonNode token)
if (token is not JsonObject obj)
throw new InvalidJsonException("XChainBridge must be a JSON object.");
+ // Exactly these four, by name. A fifth member used to be read past in silence and left
+ // out of whatever blob or id this bridge ended up in.
+ //
+ // Counting alone would not do it: three of the four plus one unknown member also comes
+ // to four, and the check would pass while the missing one reached AccountId.FromJson or
+ // Issue.FromJson as null - reporting whatever those make of nothing rather than naming
+ // the member that does not belong.
+ if (obj.Count != 4
+ || !obj.ContainsKey("LockingChainDoor")
+ || !obj.ContainsKey("LockingChainIssue")
+ || !obj.ContainsKey("IssuingChainDoor")
+ || !obj.ContainsKey("IssuingChainIssue"))
+ {
+ throw new InvalidJsonException(
+ "XChainBridge object must contain exactly 'LockingChainDoor', 'LockingChainIssue', " +
+ "'IssuingChainDoor' and 'IssuingChainIssue'.");
+ }
+
AccountId lockingDoor = AccountId.FromJson(obj["LockingChainDoor"]);
Issue lockingIssue = Issue.FromJson(obj["LockingChainIssue"]);
AccountId issuingDoor = AccountId.FromJson(obj["IssuingChainDoor"]);
diff --git a/Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj b/Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj
index d38c8777..3f3af843 100644
--- a/Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj
+++ b/Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj
@@ -13,7 +13,7 @@
Apache-2.0https://github.com/StaticBit-io/XrplCSharpXrplCSharp
- 10.11.0.0
+ 11.0.0.0
diff --git a/CHANGES.md b/CHANGES.md
index 288538ea..c5cf7a5a 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,5 +1,321 @@
# Changes
+## 11.0.0.0 08/26/2026
+
+### Migration at a glance
+
+This release makes the SDK stop misrepresenting what a node sent. It is a deliberate break with no `[Obsolete]` bridges — the same policy as `Path.TypeHex` in 10.11.0.0. Everything below is described in detail further down; this table is what will not compile.
+
+| What changes | Was | Now |
+|---|---|---|
+| 43 client members | `Task AccountInfo(...)` | `Task> AccountInfo(...)` — read `.Result`, or `var (info, raw) = await ...` |
+| `tx` lookup | `client.Tx(request)` | `client.TxV1(request)` — the version is now stated in the name |
+| Response envelope | `BaseResponse.Result` (`object`) | `RawResult` — the bytes as sent |
+| Envelope id and echo | `BaseResponse.Id`, `ErrorResponse.Request` (`object`) | `RawId`, `RawRequest` |
+| Socket entry point | `RequestManager.HandleResponse(ReadOnlySpan)` | `HandleResponse(byte[])` — a span cannot be stored, and the frame must outlive the call |
+| Model value properties | `uint Sequence` | `uint? Sequence` — on ledger models, transaction models, their `I*` interfaces and request classes |
+| 15 more response properties | non-nullable | nullable — `LOBaseLedger.LedgerIndex`, `HashOrTransaction.LedgerTransaction.Validated`, `BaseLedgerEntity.Closed`/`IBaseLedgerEntity.Closed`, `NoRippleCheck.LedgerCurrentIndex`, `ServerFeatures.LedgerIndex`/`Validated`, `LedgerStreamResponse.LedgerIndex`/`ReserveBase`/`ReserveInc`/`FeeBase`/`FeeRef`/`LedgerTime`/`TxnCount`, `LedgerStream.FeeRef`, `ValidationStream.LedgerIndex` |
+| Quorum helper | `PickWalletsForQuorum` returned `(List, uint)` | returns `(List, uint, uint)` and throws `ValidationException` when `SignerQuorum` is absent |
+| Untyped request | `Task> Request(...)` | `Task>> Request(...)` |
+| Signing helper | `GetSignedTx(tx, autofill, failHard, wallet, ...)` | `GetSignedTx(tx, autofill, wallet, ...)` — `failHard` never did anything here; pass it to `Submit`/`SubmitAndWait`, which do submit |
+| Unknown member in a nested object | dropped from the blob in silence | `InvalidJsonException` on the signing path, at any depth |
+| Path step type | `Xrpl.Models.Methods.Path` | `Xrpl.Models.Common.PathStep` — `List>` becomes `List>` |
+| `NFTokenAcceptOffer.NFTokenID` | a property the protocol has no field for | removed, from the request, the response and the interface |
+| Model helpers | `Xrpl.Models.Utils.Index` | `Xrpl.Models.Utils.ModelUtils` |
+| Stream events | `client.connection.OnTransaction += …` | also `client.OnTransaction += …` — on `IXrplClient` now; the old form still works |
+| Custom `IXrplClient` implementations | 16 events | 17 — `OnSessionEnded` has to be implemented; the client and `Connection` already carry it |
+| Socket close after a user `Disconnect()` | sometimes reported nothing at all | `OnDisconnect` fires every time |
+| `IXrplClient.connection` | `{ get; set; }` | `{ get; }` — assigning it would strand handlers on the old object |
+| Dropped stream events | invisible | `client.DroppedStreamMessages` counts them; `StreamMessageQueueCapacity` sizes the queue |
+| Frames from a stale or retiring session | delivered as current | dropped, and counted by `Connection.StaleSessionFramesDropped` |
+| Frames dispatched outside the queue | invisible | counted by `Connection.FallbackDispatchedStreamMessages` |
+| Frames answered while `OnConnected` runs | dispatched outside the queue, concurrently | queued like every other frame |
+| Stream event type | `LedgerStream.Type` was a public field | inherited `BaseStream.Type` property — source-compatible, but an assembly built against the old package needs a rebuild |
+| Stream event type value | `ResponseStreamType Type` | `ResponseStreamType? Type` — an event that carried no `type` no longer reports `UNKNOWN` as though the node had said so |
+
+Two things are worth knowing before touching the nullable properties, because both bit during this work:
+
+```csharp
+null < 1 // false — relational operators
+(null & flag) != 0 // TRUE — equality operators, the opposite way
+```
+
+A lifted `<` silently skipped a signature-quorum check; a lifted `!=` made a credential that was never accepted read as accepted, which was a fail-open in an access check. Where a value is genuinely required, fail loudly; where absence is legitimate, branch on it. `?? 0` by reflex puts the original defect back, moved from serialization into your logic.
+
+Sugar methods (`GetXrpBalance`, `GetLedgerIndex`, `SubmitAndWait`, `GetOrderBook`, `Submit`, `Autofill`) keep their signatures — they return computed values, not node responses. Three of them do gain a way to fail that they did not have before, though: `Autofill` throws when `account_info` comes back without a `Sequence`, `GetXrpFreeBalance` when it comes back without an `OwnerCount`, and the quorum helpers when a `SignerList` has no `SignerQuorum`. Each of those used to read a non-nullable property that silently defaulted to zero — which is the same lie this release removes from serialization, so failing loudly is the point rather than a side effect.
+
+
+The raw-response work, all five levels landing together. The problem: a consumer could not get the text a node actually sent — only the typed model — and re-serializing that model differed from the original in both directions, dropping members the model has no property for and inventing zeros for non-nullable CLR ones. Measured on a live ten-entry `account_tx` at `api_version = 2`: **156 fabricated members and 28 dropped**. After all five levels: **0 fabricated**, and every remaining drop is named with a reason and guarded by a test.
+
+The entries below are grouped by what changed, not by the order the levels were built in. The short version of what will not compile is in the table above.
+
+* **AMM deposit and withdrawal arithmetic, taken from rippled rather than from the formula that circulates** (#133). The SDK offered no way to work out what an `AMMDeposit` would credit before submitting it, so consumers reached for the widely quoted `T·(√(1 + b·(1 − f/2)/B) − 1)`. That formula is the right one with the fee applied loosely, and it is exact wherever there is no fee - which is what makes it hard to catch. At a 1% fee it credits **0.41244·T** where the node credits **0.41213·T**: out by 0.08%, always in the direction that promises more tokens than arrive.
+ * `Xrpl.Sugar.AmmMath` is static and needs no client: `LPTokensForSingleAssetDeposit`, `LPTokensForSingleAssetWithdraw`, `LPTokensForProportionalDeposit`, `AssetsForProportionalDeposit`, `AssetsForProportionalWithdraw`, plus `TradingFeeFraction` and `DiscountedTradingFee` with the `TradingFeeScale` (100 000) and `AuctionSlotFeeDiscount` (10) constants behind them
+ * the single-asset pair are equations 3 and 7 from rippled's `AMMHelpers.cpp` - `lpTokensOut` and `lpTokensIn` - transcribed rather than derived. The two are not symmetric, and the asymmetry is easy to get backwards: `lpTokensIn` multiplies by the fee where `lpTokensOut` multiplies by `1 − fee`. Swapping them still satisfies the round-trip inequality, which is too loose to notice, so the pair is pinned by the zero-fee identity instead: with no fee the two equations must invert each other exactly, and with the multipliers swapped they miss by a wide margin
+ * **the auction slot is the trap that makes correct formulas look wrong.** Its holder trades at `DiscountedFee`, a tenth of the pool's fee, and `AMMCreate` hands the slot to whoever created the pool - so the account most likely to be estimating is the one the pool's fee is wrong for. Estimating at the pool's fee in the integration test was out by 0.23%, three times the error of the approximation this replaces, with every equation right. Read the fee from `amm_info`'s auction slot when the account holds it
+ * **the swap, and the inverse of each equation.** `SwapAssetIn`/`SwapAssetOut` are rippled's own, equation (2) in `AMMHelpers.h`: a payment routed through a pool takes the fee off the input before the curve sees it, which is not the same number as taking it off the output. `SingleAssetDepositForLPTokens` and `SingleAssetWithdrawForLPTokens` are equations 4 and 8 - what an `AMMDeposit` carrying `LPTokenOut` will cost, and what an `AMMWithdraw` carrying `LPTokenIn` returns. Each pair composes to the identity, which is what pins the two derived through a quadratic
+ * units are the caller's and nothing converts between them, which is now said out loud because it bites: `amm_info` reports the XRP side of a pool in **drops**, so a balance read from it and an amount a caller thinks of in XRP are a million apart, and mixing them reads as a broken formula rather than a unit mistake
+ * a fee in the wrong units is refused rather than answered. `TradingFee` is in units of 1/100 000 and rippled caps it at 1000 (`kTradingFeeThreshold`, now `AmmMath.TradingFeeThreshold`); reaching for basis points or whole per cent is out by a factor of ten or a hundred, and the arithmetic notices nothing - at 5000 every intermediate value stays finite and a plausible wrong number comes back. The bound is on the fee itself, so it holds even for an amount of zero
+ * what comes back is a bound rather than the exact credit, and the direction is documented. Under `fixAMMv1_3` rippled rounds the final multiplication against the caller both ways - `lpTokensOut` downward, `lpTokensIn` upward - so a deposit is credited this much or a shade less and a withdrawal costs this much or a shade more. It lands in the last of `STAmount`'s 15 significant digits
+ * `decimal` throughout, and a square root written for it. `Math.Sqrt` carries 15 significant digits against `decimal`'s 28, and the root is the one step where the formulas need the precision
+ * checked against a node, not only against the source: five integration tests put deposits, withdrawals and a payment routed through the pool on the standalone stand and compare the estimate with what the node actually moved. Relative errors from 9.7e-17 (the swap) to 6.2e-14 - the precision the node reports balances at. That is the measurement; what the tests enforce is 1e-9, because the figures compared are differences of two reported balances and pinning them to the last digit would buy brittleness rather than coverage. The swap test also covers the one case the others cannot: an account that does **not** hold the auction slot, and therefore trades at the pool's own fee. Unit tests can only prove a formula was copied faithfully - a faithful copy of the wrong equation passes all of them
+* **`nft_info` and `nft_history`, the two Clio commands NFT work needs** (#132). Neither had a model, and neither has a substitute on a rippled node.
+ * **an owner cannot be read out of `nft_sell_offers`**, which is the natural guess. Selling a token does not remove offers for it from the ledger, so offers made by a previous owner keep being returned long after they can no longer be accepted, and the new owner has usually made none - which is exactly the state a token is in right after being bought. Taking the owner from the first offer shows the wrong account
+ * field names were taken from Clio's own handlers rather than from documentation, and one of them differs: Clio emits `nft_serial`, and its own source notes that the docs call it `nft_sequence`. A test pins the name that arrives on the wire
+ * history entries are the same shape `account_tx` returns, so `TransactionSummary` reads them - envelopes of API v1 and v2 included - rather than a second type that would have to be kept in step with the same rippled envelopes. Read them through the `I` interfaces, as with any transaction from a ledger
+ * both are Clio-only. A plain rippled node answers `unknownCmd`, which arrives as an ordinary `RippledException` carrying that code, so a consumer who has to work against both can recognise it and fall back. There is an integration test for that, against the rippled stand this suite runs on
+* **A failed submission arrives as something a caller can act on: `TransactionFailedException`** (#131). `SubmitAndWait` threw a bare `RippleException` whose only content was a sentence, so telling `tecINSUFFICIENT_PAYMENT` from `tecEXPIRED` meant reading the text - and the classes of code mean entirely different things: `tem` is a malformed request to fix, `tec` was applied with the fee taken, `ter` may work later. The hash was not available at all, and the hash is exactly what is wanted after a `tec`: the transaction is in a ledger, and showing it is the first thing anyone does.
+ * the new type carries `EngineResult`, `Hash`, `Result` (the validated transaction and its metadata) and `ReachedLedger`
+ * **nothing breaks.** It derives from `RippleException` and the message is byte-for-byte what it was - deliberately not improved while the code was open. Four integration tests in this repository assert that text word for word and needed no change, which is the same claim made from the other side
+ * `ReachedLedger` is read from the result code, not from whether `Result` happens to be present. The same failure is reported at one of two moments depending on whether the ledger closed before the first poll - after validation, with metadata, or earlier from the node's provisional answer, when only the code and the hash exist. A `tec` was applied either way, and which moment won a race is not something a caller should have to reason about. `Result` can therefore be null while `ReachedLedger` is true; `Hash` is present in both
+* **A transaction declared as its interface serializes like the transaction it is** (#127). System.Text.Json picks a converter by the **declared** type, and `[JsonConverter]` sat on the abstract class rather than on `ITransactionRequest`. A variable typed as the interface was therefore written as the interface: every field of the actual transaction type gone - a payment with no `Amount` and no `Destination` - with no exception and no warning.
+ * it worked as long as everything went through `XrplJsonOptions.Default`, whose converter list makes up for the missing attribute. Someone serializing with their own `JsonSerializerOptions` got the empty form, and found out at a node refusal or, worse, at a transaction that succeeded meaning something else
+ * two more symptoms went with it and are cured by the same line: `"TransactionType":16` instead of `"Payment"`, because that converter is on the class property too, and `SigningPublicKey`/`TransactionSignature` on the wire instead of `SigningPubKey`/`TxnSignature`, because the `[JsonPropertyName]` attributes are on the class as well
+ * declared on `ITransactionRequest` and `ITransactionResponse` in addition to the classes. Nothing else changes: the converter already dispatches on `value.GetType()`, so what it writes is what the concrete type would have written. Interfaces are exactly what one declares where the transaction type is the caller's choice - factories, submission pipelines, wallet wrappers
+* **Four places where the model said something that was not so** (#128, #129, #134, #135). None of them failed loudly: an inverted predicate returns a bool, a field the protocol does not have serializes fine, a pattern match on the wrong half of a type pair compiles and finds nothing.
+ * **`Currency.IsMPTToken` answered the exact opposite** (#128) - the negation was missing, so every amount that is *not* a multi-purpose token was reported as one, and the only kind it can be true of was reported as not. Nothing in the SDK calls it, which is why nothing showed it; the first consumer to branch on it would have taken the wrong branch every time, where the branch decides how to render an amount and how to add it up
+ * **`NFTokenAcceptOffer` no longer declares `NFTokenID`** (#129). rippled's `transactions.macro` gives that transaction exactly three of its own fields - `NFTokenBuyOffer`, `NFTokenSellOffer`, `NFTokenBrokerFee` - and the SDK's own `TxFormat` already carried the field commented out with "no need this field". The property serialized like any other, so the type suggested it, IntelliSense offered it, and whoever filled it in got a node refusal with no hint from the types. Removed rather than obsoleted, the same clean break as `Models.Path` above
+ * **`ValidateNFTokenAcceptOffer` now refuses the same offer on both sides** (#134). rippled compares each offer's owner against the submitter *separately* - the two blocks in its `preclaim` read like a choice between direct and brokered mode and are not one - so naming one offer twice makes one of those comparisons the account against itself: `tecCANT_ACCEPT_OWN_NFTOKEN_OFFER`, in a ledger, fee taken. One comparison catches it with nothing to ask the node. The same doc comment now also records that brokered mode needs three distinct accounts, which the C++ reads as saying the opposite. Note that this validator is opt-in: unlike the memo rules above, `Validation.Validate` runs only when a consumer calls it
+ * **Reading history is documented** (#135). What comes back from `account_tx`, `tx` or `SubmitAndWait` is the *response* half of a type pair, so `summary.Transaction is NFTokenCreateOffer` compiles, warns about nothing and never matches - which looks exactly like the response having failed to parse. Request and response types come in pairs that share an `I` interface; match on that. Written on `TransactionSummary.Transaction`, where the confusion happens, and in the README. The advice was measured before it was given, and a test holds it to that: it applies to 77 pairs and not to five - the `ConfidentialMPT` transactions carry no interface at all, on either half, so there is nothing to match on there. Those five are named in the test rather than hidden by it, which is also what makes a sixth such type fail here instead of in a consumer's silent non-match
+* **A memo a node will refuse is refused before signing** (#119). rippled caps the serialized `Memos` array at **1024 bytes** in `passesLocalChecks` → `isMemoOkay`. That is a local check: the transaction is not relayed, reaches no ledger and costs no fee - the consumer simply finds out, after building, autofilling and signing it, that the node will not take it, and the answer names no field. The SDK checked the array's shape and never its size.
+ * `MemoRules.Validate` is called from every public entry that signs - `Sign`, `SignAsBatchPart`, `SignAsSponsor`, `SignAsLoanCounterparty` - and deliberately not from `Validation.Validate`, which production code calls nowhere and which would have made this a rule nobody runs. Guarding `Sign` alone was the first attempt and was not enough: the SDK's own multi-batch submission signs batch parts directly, so that path went unchecked
+ * measured the way the node measures it, by serializing the array through the codec rather than counting bytes by hand: one object start marker, the fields with their length prefixes, one end marker, and no array markers. In practice **1019 bytes** of `MemoData` fit in a memo carrying nothing else. The limit is on the array, so splitting the content across several memos does not raise it - the exception says so, because trying that is the obvious next move
+ * `MemoType` and `MemoFormat` must also decode to characters RFC 3986 allows in a URL; the exception names the offending byte. `MemoData` is exempt, which is the whole point of it
+ * two of the node's five memo rules are **not** repeated here: a member other than `MemoType`/`MemoData`/`MemoFormat` inside a `Memo`, and a value that is not hex, are already refused by the codec - and refused better, since it names the member at fault. An earlier draft of this check reported them first and in poorer words, which two existing tests caught
+ * verified against a real node, both directions: on the standalone stand a memo of exactly the limit reaches a ledger, and one byte over is refused with *"The memo exceeds the maximum allowed size."* A constant read off a source file can be wrong by being too strict as easily as too loose, and only the node can settle it
+* **Giving up on a connection reports giving up, not a cancellation nobody asked for** (#122). `Connect()` is two operations, not one — the connection, and the `server_info` that `SetNetworkId` sends straight after it. The socket really does open for a moment before a failing `OnConnected` handler brings it down, so the wait can return successfully and the caller can already be inside that second operation when the client gives up. The give-up path went through `Disconnect()`, which rejects everything in flight with `OperationCanceledException` — right for a close the caller asked for, wrong for a failure. A consumer that catches cancellation to tell "someone cancelled this" from "this failed" was told the first when the second had happened.
+ * the give-up path now rejects in-flight requests with `NotConnectedException` before disconnecting, so the caller hears the same thing whichever of the two operations they were in
+ * **the same wrong answer came out of the path next door, and there it was worse.** A handler that fails once and works on the next attempt is precisely what the reconnect path is *for* - but the teardown in between rejected the caller's in-flight `server_info` too, so `Connect()` threw while the client went on to connect. Measured: the caller got `OperationCanceledException` and `IsConnected()` was `true` three seconds later. `Connect()` now waits for the client's own recovery and asks again, so a failure it recovered from does not reach the caller at all. Asking "is it connected right now?" instead would not have worked - at that moment the socket has just been torn down and the answer is no, however well the recovery is going
+ * that wait is also what keeps the terminal case honest: it ends when the connection is back, or throws `NotConnectedException` when the client gave up
+ * **the flake is now a test.** This failed on CI about every other run and never once in 37 local runs, which is why it sat open with the mechanism unproven. Holding the mock's `server_info` back with a delay puts the request in flight for certain: the reproduction failed three times out of three before the fix and passes after it
+* **A session that ends now says so, whatever ended it: `OnSessionEnded`** (#123). Subscriptions live on the node against one connection, so when the connection goes they go with it and the consumer has to resubscribe. Knowing *when* was the problem: a session could end in more than one way, and not every one of them said so.
+ * **`ChangeServer` announced nothing at all.** It marks the old session retiring, which makes the socket's own close callback return early by design, and the only notification it sends is a `Connecting` status — the same one a first connection sends. The client went on reporting `Connected`, the button still said "subscribed", and the stream was dead for good. Reproduced three times in the Blazor demo in this repository (mainnet → testnet, testnet → devnet, mainnet → testnet): after the switch, **0 transactions and 0 ledgers**, while a manual resubscribe brought the stream straight back — so the connection was fine and it was the subscription that had gone
+ * **the fast-reconnect path** — a ping timeout or a network drop — sent only a `RestoringConnection` status, which says the connection is being rebuilt, not that everything bound to the old one is gone. A consumer had to infer the second from the first
+ * **a user `Disconnect()` was not reliably announced either — and neither was `OnDisconnect`.** The receive loop had one way out that reported nothing: its own `while` condition. A message handler runs inline on that thread and the request continuation it completes runs inline in turn, so a caller that disconnects right after the response that woke it does so *inside* the loop. The loop comes back round, finds the cancellation already set, and leaves silently. Cancelling a *parked* receive throws and is reported normally — which is why this looked correct against a slow peer and vanished against a fast one. That exit now reports the close like every other, so `OnDisconnect` fires on this path too and the session end rides along with it
+ * **what was covered was a socket closing by itself**, by `OnDisconnect` - which is exactly why a consumer that resubscribed on disconnect looked correct and still lost the stream on the paths above
+ * the fix announces the end explicitly on the two deliberate retirement paths, which no callback can speak for, and leaves the rest to the socket's close callback — now that it always runs. A guard on the session object keeps it to one announcement per session
+ * `OnDisconnect` is unchanged and still means what it meant — a socket closed. `OnSessionEnded` is the one thing to subscribe to in order to know a resubscribe is due, and carries a `SessionEndReason` (`ServerChanged`, `ConnectionLost`, `UserDisconnected`) so a consumer can tell a switch from a failure from its own doing
+ * it does **not** fire for a connection attempt that never succeeded: there was no session, and so no subscription, to lose
+ * **this was never a regression.** The same scenario reproduces identically on `origin/release` (`Xrpl` 10.12.0.0), before all of the stream work in this release — same `Connected`, same missing restoration, 33 seconds of silence. The demo did not change between the two, and the `connection.cs` diff touches none of the retirement path. The defect was simply silent, and making the stream observable is what made it visible
+ * **not** automatic resubscription (#104), which is a larger question and would need the SDK to hold subscription state. This gives the consumer the signal and leaves the decision with them
+* **`Models.Path` is a path step, and now says so: `Xrpl.Models.Common.PathStep`** (#117). The type describes one step of one path, not a path, and the old name cost three separate things.
+ * **it collided with `System.IO.Path`.** The proof was already in this repository: `TestUResponseFidelity.cs` was the one test file importing both `System.IO` and `Xrpl.Models.Methods`, and it had to write `System.IO.Path.Combine` in three places while its neighbours wrote `Path.Combine`. Those three qualifications are gone in this change, which is the check that the collision is gone with them. Consumers paid more: with `ImplicitUsings` on, a single `using Xrpl.Models.Methods;` was enough to turn any `Path.Combine` in the file into `CS0104`
+ * **it collided with `Xrpl.BinaryCodec.Types.Path`**, which is a whole path — so one name meant a container in one half of the SDK and its element in the other. No file imported both, so nothing had failed yet; the codec keeps its `Path`, where the name is right
+ * **everything around it already said step**: `PathStepType`, `Validation.IsPathStep`, `TestUPathStep`, and xrpl.js, where this is `PathStep` and `Path = PathStep[]`. `List>` read as a list of lists of paths while meaning a list of paths
+ * the wire format does not change. Only the C# name moves; the `[JsonPropertyName]` on `account`, `currency`, `issuer`, `mpt_issuance_id` and `type` are untouched, so serialization and signing are identical
+ * **no `[Obsolete]` bridge, because there cannot be one**: `[Obsolete] class Path : PathStep {}` would not help, since generics are invariant and `List>` still would not convert. It would add a type to the public surface and compile nothing that did not compile anyway
+ * two files stopped needing `using Xrpl.Models.Methods;` altogether once the type moved - `Payment.cs` and `TestUPathStep.cs` - so they no longer drag in the namespace that caused the collision in the first place. Both `using` directives are deleted rather than left as decoration
+ * **migration**: replace `Path` with `PathStep` and add `using Xrpl.Models.Common;`, or put `using Path = Xrpl.Models.Common.PathStep;` at the top of the file for now
+* **`Xrpl.Models.Utils.Index` is now `ModelUtils`** (#117, the same defect one layer over). `Index` was a calque of the barrel file `utils/index.ts` it was ported from, and it collides with `System.Index`, which is in scope in every file whether anyone asked for it or not. `Payment.cs` carried `using Index = Xrpl.Models.Utils.Index;` — an alias that existed for no other reason than to work around that collision, and is now deleted. The class also finally matches `ModelUtils.cs`, the file it always lived in
+* **`Xrpl` goes to 11.0.0.0** (from 10.12.0.0), the major this release has been heading for since the first breaking change in it. `Xrpl.BinaryCodec` is already at 11.0.0.0; `Xrpl.AddressCodec` and `Xrpl.Keypairs` stay at 10.9.0.0, untouched since the last release
+
+* **Fourteen response fields the node sends now have typed properties** (#106). Thirteen was the count in the report; measuring found one more, and the arithmetic below is ten plus one plus two plus one. Unknown-field capture made the loss visible instead of silent; these were the ones it found. Capture is the safety net, declaring them is the fix, and a field counts as done only when it is a declared property **and** gone from `UnknownFields` - either half alone can pass while the other fails.
+ * `ServerInfo.Info` gains **ten**, not the seven the report listed. Measuring against a node rather than working from the list found three more - `git`, `node_size` and `validator_list`. The test asserts the whole capture is empty rather than a list of names, precisely so it cannot miss what nobody thought of
+ * the types came from a node too, not from documentation. `server_state_duration_us` is a **string** in `server_info` while the same field is a number in `server_state`; `initial_sync_duration_us`, `jq_trans_overflow`, `peer_disconnects`, `peer_disconnects_resources` and `time` are all strings. `ports` is a list of `{port, protocol[]}`, and `git` and `validator_list` are objects, so three small types come with them
+ * `AccountLines.Validated` - the one sibling result model that never declared it, though rippled writes it through `lookupLedger` unconditionally. `AccountInfo`, `AccountObjects`, `AccountNFTs`, `AccountCurrencies` and `NoRippleCheck` have always had it
+ * `LOLedger.ClosedLedger` and `LOLedger.OpenLedger` - a `ledger` call naming no ledger answers with two whole structures rather than one. Not to be confused with `BaseLedgerEntity.Closed`, which is the boolean *inside* a ledger: the same word for two different things is why these went unnoticed
+ * `LOEscrow.Flags`, replacing a `//todo` that reasoned a field which is always zero need not be modelled. That confuses "always zero" with "never sent" - it arrives on every deleted Escrow node in transaction metadata. A plain number rather than an enum, because no `lsfEscrow*` flag is defined and inventing an empty enum would claim a vocabulary that does not exist
+
+* **An unknown member one level down no longer disappears from what gets signed** (#107). `StObject.FromJson` refused members this codec does not know - but only at the top level. Nested objects reached it through a dispatch table whose delegate signature carries no strictness flag, so they went through the lenient overload and the member was dropped without a word.
+ * the cost was show-one-sign-another, arriving from the outgoing side: a caller is shown a transaction carrying a member - a typo in a field name, or a field from an amendment newer than this SDK's `definitions.json` - signs it, submits it, and it lands in the ledger **without** that member. The top level failed loudly; one level down it did not
+ * rippled does the opposite. `STParsedJSON::parseObject` recurses and answers `unknownField` at every level, so such a transaction does not parse at all
+ * **the Batch case was worse than reported.** `BatchNormalizer.ComputeInnerTxId` parsed leniently too, and that id is what the outer Batch signature commits to - so the signature fixed an inner transaction other than the one the caller was shown. Strict there means strict *without* the signing filter: an id covers the whole transaction, so dropping non-signing fields would hash something else again
+ * **`Xrpl.BinaryCodec` goes to 11.0.0.0** (from 10.11.0.0). The package changes behaviour for input that used to succeed and gains a public member, so it takes the major with `Xrpl` rather than trailing behind. `Xrpl.AddressCodec` and `Xrpl.Keypairs` are untouched since the last release and stay where they are - which is correct, not an oversight: they are consumed by `ProjectReference`, so a newer `Xrpl` keeps depending on the versions already published
+ * **new on `Xrpl.BinaryCodec`**: `StObject.FromJsonStrict(JsonNode)` - the strict parse without the signing filter. Public because it is needed from the `Xrpl` package and the two assemblies share no `InternalsVisibleTo`
+ * **strictness and the signing filter are now separate.** One flag used to mean both "refuse unknown members" and "drop non-signing fields"; only the first recurses. `FilterIsSigning` still applies to the top level alone, because filtering nested objects would change what gets signed
+ * **`Encode` stays lenient about unknown *fields*, deliberately** - members of an `STObject`, at any depth. Of its twelve call sites, four run a transaction through the codec to answer a question about it - `IsSigned`, `IsAccountDelete`, `GetLastLedgerSequence`, `ValidateTransactionEquivalence` - and `HashSignedTx` hashes transactions that came from the node. Making that strict would turn a predicate into an exception and would fail on any response carrying a field newer than this SDK's `definitions.json`, which is the forward compatibility the raw-JSON work exists to keep
+ * it is **not** lenient about unknown members of the self-parsing object-valued types, and never fully was: `Issue`'s two standard forms have counted their members since long before this change, so `Encode` already threw on a malformed Issue. The line is between an open shape and a closed one. A transaction gains fields with every amendment, and dropping one still leaves a valid transaction minus a field. `Issue`, `XChainBridge` and a path step have fixed shapes that cannot serialize a member outside them at all - dropping one does not yield the same structure minus a member, it yields a different structure, and any blob or hash over it is simply wrong
+ * **object-valued fields that are not `StObject` were a second hole**, found in review. `Issue` in its MPT form, `XChainBridgeType` and the steps of a `PathSet` parse themselves rather than going through the recursion, and each read past members it did not know. `Issue`'s two other forms have always counted theirs, so the MPT form was the odd one out; the other two now count as well
+ * `type` and `type_hex` are named as members a path step may carry, because refusing them would break the ordinary flow: `ripple_path_find` answers with a `type` on every step, this SDK declares it on `Path` and emits it back out, so a path taken from a response and put into a payment carries it. The byte is synthesised from which of account, currency and issuer are present, making the member redundant rather than unknown. A test pins that, and it is the only thing between this change and a broken path-finding flow
+ * **migration**: a caller passing a transaction with a member this SDK does not know used to get a silently reduced blob and now gets an exception. If the member is real and new, update the SDK; if it is a typo, this is the error that was missing
+
+* **`failHard` removed from `GetSignedTx`.** The parameter was declared and never read: the method autofills, signs and encodes, and nothing there talks to the network, so there is no submission to fail hard about. `Submit` even passed an explicit `failHard: false` into it while keeping its own value for the actual submit - the author already knew it meant nothing. Callers of `Submit` and `SubmitAndWait` are unaffected; a direct caller of `GetSignedTx` drops the argument. Positional callers get a compile error rather than a silent rebind, since the next parameter is an `XrplWallet`
+
+* **The stream queue now exists before consumer code can subscribe** (#113). `StartMessageProcessor` ran at the very end of `OnceOpen`, after `ResolveAllAwaiting()` and after the `OnConnected` callback. Subscribing from that callback is the ordinary pattern - it is what the wallet consuming this SDK does - and the node can answer the subscription before the handler returns, so the first frames found no channel and took the fallback: outside `StreamMessageQueueCapacity`, uncounted by `DroppedStreamMessages`, dispatched concurrently rather than one at a time. The events most likely to arrive out of order were precisely the first ones after connecting.
+ * **the blocker was an unrelated coupling, and that is the real fix.** `StartPingTimer` begins with `StopPingTimerSync`, which also called `StopMessageProcessor`, so simply starting the processor earlier had it torn down again moments later - measurably: `TestUDroppedStreamMessagesCountsWhatTheConsumerNeverSaw` went from 10 evictions to 0. Stopping a ping timer has no business stopping the message queue; the two lifecycles are now separate
+ * the processor is stopped explicitly where a connection genuinely ends - `Disconnect`, `DisconnectAndWaitAsync`, `OnceClose`, `OnConnectHandlerFailedAsync`, `ChangeServer` and `RetireCurrentSessionAndReconnectAsync` - rather than by side effect of a timer
+ * the fallback path stays, and that was measured rather than assumed: declaring it unreachable fails 6 tests. It still serves `OnMessage` on a client that never connected, anything arriving after the processor is stopped, and a write the channel refuses because its writer is already completed
+ * `FallbackDispatchedStreamMessages`, added alongside, is what makes this checkable from outside. It counts cumulatively and the fallback stays legitimate outside a connection, so the thing to watch is not an absolute zero but a zero *delta*: the counter should not move while a connection is being established
+
+* **The model stopped inventing values it was never sent, and stopped losing ones it does not know** (**breaking**) — the second half of the raw-response work. Re-serializing a typed response used to differ from what arrived in both directions. Measured on live mainnet responses at `api_version = 2`: a ten-entry `account_tx` gained **156 fabricated members** and dropped 28. After this level the same capture gains **4** and drops 15, and every fabricated member left is the `Amount`/`DeliverMax` rename, which is the next level.
+ * **Scope came from the protocol, not from a hand count.** The repository already vendors rippled's `ledger_entries.macro` and parses which fields are Required, Optional or Default. A new conformance test, `TestUNullabilityConformance`, reads it: a field the protocol allows to be absent must map to a property that can express absence. It found **9 direct violations** — `AMM.TradingFee`, `FeeSettings.ReferenceFeeUnits/ReserveBase/ReserveIncrement`, `LedgerHashes.FirstLedgerSequence/LastLedgerSequence`, `MPTokenIssuance.AssetScale`, `PayChannel.SourceTag/DestinationTag`
+ * **A second, broader rule applies to the same models.** rippled's requirement flag describes the ledger object, but these models double as the contents of `PreviousFields`, `FinalFields` and `NewFields` — and `PreviousFields` carries only the members a transaction changed, so there even a Required field can be missing. Hence every value-typed property of a ledger-entry model is now nullable. Counted as the conformance test counts them — pairs of (model, property), which is what the defect is measured in — that is **80 pairs**; as a diff it is **50 property declarations across 23 files**, the difference being `LedgerEntryType` on the shared base, which one edit covers for all 31 models. On top of that, 21 properties on transaction models and `AccountInfo.LedgerIndex`/`LedgerCurrentIndex`
+ * `LedgerEntryType` became nullable too, and the constructors that stamped it were removed along with the unconditional stamp in `LedgerObjectConverter`. Without both, the property stayed non-null by construction and the change would have been decorative — round-trip on a `ModifiedNode` went from 6 fabricated members to 2 to **0**
+ * `LONFTokenPage.PreviousTxnLgrSeq` was `long` while `definitions.json` declares the field `UInt32` and every other model uses `uint`. Corrected to `uint?` — unrelated to nullability, found while surveying
+ * **`[JsonExtensionData]` on `BaseLedgerEntry` and `BaseTransactionResponse`.** A member no model knows — a field arriving with a new amendment — used to vanish silently. It now lands in `UnknownFields` and survives a round trip. Verified that the hand-written converters (`LOConverter`, `ModifiedNodeConverter`, `TransactionResponseConverter` and the rest) do not swallow it: they parse only the envelope and delegate the fields to the reflection path, which honours the attribute
+ * **`BaseResponse.Id` and `ErrorResponse.Request` left `object`** — the known remainder of the first level. Both were filled with a `JsonElement` whose pooled array is never returned; measured at **3 672 B retained per envelope carrying an `id` against 217 B without one**, on every response. Both now record bounds, exposed as `RawId` and `RawRequest`, and the request id is parsed straight from the bytes with `Utf8Parser` instead of being formatted into a string first. The retention budget tightened from 8 192 to 6 144 bytes
+
+ **Migrating.** The common case:
+
+ ```csharp
+ // was
+ uint sequence = accountRoot.Sequence;
+
+ // now — decide what absence means before you write anything
+ if (accountRoot.Sequence is not { } sequence)
+ {
+ throw new InvalidOperationException("account_info returned no Sequence");
+ }
+ ```
+
+ Reach for `?? 0` only where zero is a legitimate value of the field, not merely a way to make the code compile. Substituting zero elsewhere puts back exactly the defect this change removes — it just moves the lie out of serialization and into your logic. Two places inside the SDK showed why this matters, both found while migrating and both fixed with an explicit failure instead of a default:
+
+ * a lifted `collected < SignerQuorum` returns **false** when the quorum is absent, so the "insufficient signatures" check silently stopped firing
+ * a lifted `(Flags & lsfAccepted) != 0` returns **true** when `Flags` is absent — the opposite direction from `<` — so a credential that was never accepted read as accepted. That one was a fail-open in a permissioned-domain access check
+
+ If a value is genuinely required, fail loudly; if absence is legitimate, branch on it. Do not reach for `?? 0` by reflex.
+
+* **The accuracy above is now held by a test, not by hand measurement.** Every figure quoted in this section came from a throwaway console project built, run and deleted. That meant any of these defects could come back unnoticed — which is exactly how `LOAccountRoot` went without `WalletLocator`/`WalletSize` until a manual pass, and how `sfLEVersion` had to be caught by a protocol-watch notification instead of a red test.
+ * `Tests/Xrpl.Tests/Fixtures/Responses/` holds seven live mainnet responses — six captured at `api_version: 2` and one at v1, which is what catches the case where the node sends both `Amount` and `DeliverMax` for the same value — including one with `binary: true`, one carrying `warning: "load"`, and a ten-entry `account_tx` with the metadata that started all of this. A `README.md` beside them records where and when they came from, because updating them has to be a deliberate act
+ * `TestUResponseFidelity` deserializes each one, serializes it back and compares the trees. **Fabricated members must be zero, with no exceptions** — a fabrication is a claim the node said something it did not. Dropped members are checked against an explicit list where each entry carries its reason; anything not on that list fails the test. The list turns "we think we know what we lose" into a checked fact
+ * the test is proven to fail, not merely to pass: reverting a single property to non-nullable produced **24 fabrications** across three fixtures, and removing `[JsonExtensionData]` produced **3 drops**. A test that stays green when a defect is reintroduced guards nothing
+ * two members remain knowingly dropped and are recorded as such: `$.status`, which lives outside `result` in the WebSocket envelope and reaches callers through `XrplResponse.Status`; and `$.warning` inside `account_objects`, found during this work and not yet closed
+
+* **Level 3: API v1 and v2 are told apart, and the fields neither model had a place for are filled in.** The last class of defect the raw-response effort set out to remove: a field the node sent under one valid protocol name coming back under a different one, and a client method that silently spoke a different API version than the one it was configured for. Measured on the same live ten-entry `account_tx` capture used throughout this section: **156 fabricated members before any of this work, 0 after it** — the last 4 (the `Amount`/`DeliverMax` rename, called out as "the next level" above) are gone along with the rest.
+
+ * **`PaymentResponse` no longer substitutes `Amount` for `DeliverMax` on the way back out** (**breaking**) — the worst class of the three remaining defects, and the reason it got called out separately from the rest of the nullability work: not a lost field, but a *substitution* for a different, only superficially-equivalent protocol field name. A wallet's reconciliation screen — the one thing that exists so a person can check what they are about to sign — would show a field the node never sent. `PaymentResponse` now remembers which name a value arrived under — two independent presence flags, one per name, set by whichever of the two mirrored JSON properties fired on read — and serializes it back under that same name. Two flags rather than one because API v1 sends **both** names for the same amount: a single flag would be overwritten by whichever setter ran second, and one of the two names would be dropped. So: `Amount` alone comes back as `Amount`, `DeliverMax` alone as `DeliverMax`, both as both, and an object built in code as `Amount`. `Amount` itself stays the one public property a caller reads — it is excluded from JSON directly, so nothing about reading it changed; only which wire name the value goes back out under did.
+ * **`Payment` (the request/signing class) is deliberately left asymmetric.** It still always writes `Amount`, regardless of which name a value came in under — this is not an oversight, and the reasoning is now recorded on the property itself (`Payment.cs`, the `DeliverMax` alias), not just here. Checked directly against `Base/Xrpl.BinaryCodec/Enums/definitions.json`: `Amount`, `DeliverMin` and `SendMax` all have an entry there; `DeliverMax` does not, because it is a JSON API v2 presentation-layer rename with no binary field code of its own. `Payment.ToJson()` feeds `XrplWallet.Sign()` → `EncodeForSigning`, which looks fields up by name in `definitions.json` — an object that re-emitted "DeliverMax" would have the codec silently fail to find the field and drop the amount from the signed blob, the worst possible outcome, a transaction signed with no amount in it. `PaymentResponse` is read-only display data and never reaches the codec, so it alone is safe to make symmetric. An existing test, `TestPaymentDoesNotSerializeDeliverMax`, already pinned the asymmetric contract before this change gave it a documented reason
+ * **`Tx()` renamed to `TxV1()`** (**breaking**) — it pinned `request.ApiVersion = 1` regardless of `ClientOptions.ApiVersion` (which defaults to 2), the one place in the SDK where the choice of *method* — not a setting — silently decided the protocol version. It cannot be made to honor the client setting instead: `TransactionResponse` has no field for API v2's `tx_json`, so handing it a v2 payload would lose the transaction wholesale, not just a field name, the way `PaymentResponse` above does. Since the behavior has to stay, the name now says what it does:
+
+ ```csharp
+ // was — silently API v1, independently of ClientOptions.ApiVersion
+ TransactionResponse tx = (await client.Tx(request)).Result;
+
+ // now — the method name carries the protocol version instead of hiding it
+ TransactionResponse tx = (await client.TxV1(request)).Result;
+ ```
+
+ `TxV2(...)`, unchanged, sits beside it for the `tx_json`/`meta` shape. No `[Obsolete]` shim, consistent with this repository's major-version policy. Six integration test files and one hand-written `IXrplClient` test double were the only callers; nothing inside the SDK itself called `Tx()`
+ * **`close_time_iso`, `ctid`, `status`, `meta_blob`, `tx_blob` — the remaining unmodeled fields, added where rippled actually puts them, not where it would be convenient.** `close_time_iso` and `ctid` now live on `IBaseTransactionResponse`/`BaseTransactionResponse`, reached by every transaction response type. `ctid` also exists separately on `TransactionSummary` (`Models/Methods/AccountTransactions.cs`), and both are real: the singular `tx` method reports `ctid` as a *sibling* of `tx_json`, which is what `TransactionSummary.Ctid` reads, while `account_tx` nests `ctid` *inside* `tx_json` itself, which lands on the transaction's own `Ctid` instead — two different positions in the protocol, not one field modeled twice by mistake. `status` was closed by giving `XrplResponse` its own `Status` member: it sits beside `result` in the envelope, not inside it, so it was never reachable through `Raw`. `meta_blob`/`tx_blob` cover API v2 with `binary: true`, where rippled sends the transaction and its metadata as top-level hex siblings instead of the usual `tx_json`/`meta` — before these existed, that response shape lost its body wholesale (measured: 2246 B in, 195 B out)
+ * **`FromStringDateTimeConverter` silently returned `null` for every timestamp rippled actually sends.** Not a missing-field defect like the rest of this level — the property already existed — but a parser that never matched real input, which reads the same as a missing field from the outside. It parsed with the exact format `"yyyy-MM-ddTHH:mm:sszzz"`, which only accepts a numeric UTC offset (`+00:00`); rippled sends `close_time_iso` with a literal `Z` suffix (`"2013-03-12T23:16:50Z"`), which `zzz` rejects. `TryParseExact` returned `false` on every real capture, so `close_time_iso` came back `null` regardless of whether a property was there to receive it. Fixed by switching to the `"K"` custom specifier, which accepts both forms, plus `DateTimeStyles.AdjustToUniversal` so a numeric-offset value still normalizes to UTC instead of being left in local time
+ * `TestUBaseTransactionResponseFields` and `TestUPaymentDeliverMaxRoundTrip` pin all of the above against real captures — a live `tx` response, a live `account_tx` entry reshaped to the API v1 wire form the way rippled genuinely flattens it, and a live `binary: true` capture — round-tripping through deserialize → serialize and asserting the wire name that comes back, not just that a value is present
+
+* **Methods now return `XrplResponse`: the typed result and, beside it, the bytes the node sent** (**breaking**) — the point of the whole effort. A consumer could not get what a node actually said, only the model, and re-serializing that model differs from the original in both directions. Measured on live mainnet responses at `api_version = 2`: `close_time_iso`, `ctid`, `tx_json.DeliverMax` and `meta_blob` are dropped; `PreviousFields.Flags = 0`, `LedgerEntryType` and — on a Payment — `TransactionType = "AccountSet"` are invented, 156 fabricated members on a ten-entry `account_tx` alone. For a wallet rendering a transaction so a person can check what they are signing, that is false precision.
+ * `XrplResponse` carries `Result` (the projection), `Raw` (the `result` member exactly as sent), and the envelope the client used to unwrap and discard: `ApiVersion`, `Warning`, `Warnings`, `Forwarded`. `Warnings` is never null
+ * **no implicit conversion to `T`, deliberately.** Measured against this codebase it would carry fewer than half the call sites — 248 with an explicit type against 273 using `var`, which break either way — leaving a partial compatibility harder to migrate than a clean break, and hiding that `Raw` exists at all
+ * `RawJson` gained `Deserialize()`, `ToJsonElement()` and `HasTopLevelProperty()`, so a consumer does not reach for `JsonSerializer` with options of their own — the XRPL models depend on the converters in `XrplJsonOptions.Default`, and bare options silently produce a different object
+ * the envelope is paired with its frame through `AttachFrame(byte[])` rather than a settable property, so the bounds are checked once where the frame and the recorded slice meet instead of lazily inside every read of `RawResult`. The method is `internal` on purpose — see the note on Stream overloads below. (No public member was removed here: the settable form existed only between commits of this release)
+ * `warning` — the literal `"load"`, rippled's rate-limit signal — reaches the caller for the first time. It is not reachable through `Raw` either: that is the `result` member, while `warning` lives in the envelope around it
+
+ **Migrating.** There is no `[Obsolete]` shim, so here is the move:
+
+ ```csharp
+ // was
+ AccountInfo info = await client.AccountInfo(request);
+
+ // now
+ AccountInfo info = (await client.AccountInfo(request)).Result;
+
+ // and what the work was for
+ XrplResponse response = await client.AccountInfo(request);
+ string asTheNodeSentIt = response.Raw.ToString();
+ ```
+
+ * **the `tx` method pins `api_version = 1` regardless of `ClientOptions.ApiVersion`**, so its `Raw` is the honest text of a *v1* response. A caller on API v2 — a wallet checking what it is about to sign — wants `TxV2(...)`, which maps `tx_json` and `meta` as siblings the way v2 sends them. This is resolved further down in this same release: the method is renamed `TxV1()` so the version is stated in the name rather than hidden in the body
+ * **breaking:** 43 members change return type — 41 typed methods, `Request(Dictionary)`, and `GRequest` itself. `Connection.Request` and `Connection.GRequest` change with them, and `RequestManager`'s `XrplRequest.Promise` / `XrplGRequest.Promise` now resolve to `ResolvedResponse` rather than the value directly. `ResolvedResponse` and the `XrplResponse.From` unpacker are public for exactly that reason: `RequestManager` is public, and a caller working at that level has to be able to name what it gets back. `From` has a second overload that takes a `ResolvedResponse` directly, for a caller that already has one off an awaited `Promise` — the mismatch the `object` overload can only catch at run time, as an `XrplException`, becomes a compile error through this one
+ * **not affected:** the sugar methods — `GetXrpBalance`, `GetLedgerIndex`, `SubmitAndWait`, `GetOrderBook`, `Submit(ITransactionRequest, …)` — keep their existing return types. They were already handing back the typed result before this change and still do; nothing about them moved
+ * `Request(...)` and `GRequest(...)` are no longer `async` methods — they delegate straight to `Connection`'s versions of themselves. An argument-validation exception they raise now leaves the call synchronously, before a `Task` is even returned, rather than surfacing when the returned task is awaited
+ * `XrplResponse` gained `Deconstruct(out T, out RawJson)`, so `var (result, raw) = await client.AccountInfo(request)` works — the one-line fix for the call sites that broke hardest on this change, the ones using `var` — and `HasNextPage`, reading the same `marker` signal as the `BaseResponse.HasNextPage()` extension — which is still there and was fixed in this same release — but reachable from the type a caller of the client's own methods actually holds
+ * `TestUXrplResponse` proves the feature end to end over a socket: a scripted response with irregular whitespace and a member no model knows comes back byte for byte in `Raw`. That assertion originally had a second half — the same member proved absent from the re-serialized `Result` — which extension-data capture later made false; it now proves the opposite, that the member reaches the caller on both sides, while `Raw` remains the only byte-exact one. The envelope is asserted on both the typed and the untyped path
+ * costs nothing measurable: `XrplResponse` is a 64-byte readonly struct for a reference-typed `T` (`Unsafe.SizeOf`; 56 bytes for a value-typed `T`, and none of the 43 methods are parameterized with one, so 64 is the figure that applies in practice — it grew by 8 when `Warning` and `Status` were added to the envelope later in this release) that lands directly in the async method's result field, and all three allocation budgets are unchanged after the switch — 1.89x on the `JsonElement` path, 5.57x typed, 2.18x over the socket
+
+* **The `result` member was parsed twice and its intermediate document was never given back** (**breaking**) — `BaseResponse.Result` was typed `object`, which System.Text.Json fills with a self-contained `JsonElement`. Building it costs a `JsonDocument.ParseValue`, which rents its backing array from `ArrayPool` and never returns it: **65 536 bytes rented for a 36 691-byte response**, held for a subtree that was then deserialized a second time to reach the requested type. The envelope now records *where* `result` sits instead of materializing it, and the requested type is cut straight from those bytes — one parse, no intermediate document, nothing left unreturned.
+ * `JsonSlice` (byte offset + length) and `JsonSliceConverter`, which reaches the bounds through `Utf8JsonReader.TokenStartIndex` / `Skip()` / `BytesConsumed` without materializing the subtree. `Write` throws `NotSupportedException`: a response envelope describes what a node sent, and re-emitting it from the parsed form is exactly the plausible-but-different document this work exists to remove
+ * `RawJson` — a window onto the frame rather than a copy of it. The frame is the exact-sized `new byte[]` the receive loop already allocates per message, so holding the window costs nothing beyond keeping that array alive. UTF-16 is never stored; `ToString()` builds it on demand. `ToArray()` is the documented way to outlive the response without pinning the whole frame
+ * `RequestManager.HandleResponse` takes the frame and pairs it with the bounds. **The `string` overload now encodes to UTF-8 first, and that is required, not incidental**: on `Deserialize(string)` System.Text.Json transcodes into a buffer of its own, so the bounds would be relative to that buffer, unpairable, and the result would be lost on every response. Measured, the explicit array is still cheaper than what it replaced — 94 016 → 54 200 B per message — because the old path paid the same transcode plus the unreturned rental
+ * **breaking:** `BaseResponse.Result` is gone, replaced by `RawResult` — the bytes as sent. The bounds themselves are an implementation detail and stay internal. `RequestManager.HandleResponse(ReadOnlySpan)` is gone, replaced by `HandleResponse(byte[])` — a span cannot be stored, and the frame must now outlive the call. As a consequence `HandleResponse(null)` no longer compiles: it is ambiguous between the `string` and `byte[]` overloads. No `[Obsolete]` grace period, consistent with `Path.TypeHex` in 10.11.0.0
+ * **ownership moved with the signature.** The returned response keeps the array and cuts `RawResult` from it, so a caller must not reuse or mutate the buffer it passed — a pooled or ring buffer would silently rewrite a response already handed out. `TestUResponseAliasesTheFrameItWasGiven` pins this
+ * the frame reference is deliberately `internal`. The bounds are only meaningful for a reader that covered one contiguous buffer, which the `Stream` overloads do not — there the offsets come out relative to the chunk, wrong and with no exception to say so (measured: offset 40 012 instead of 40 019 on a 40 KB payload). Keeping it unreachable from outside disarms that path by construction: an envelope a consumer deserializes from a stream has no frame, so `RawResult` comes back empty rather than pointing at bytes nobody checked
+ * **migrating off `Result`.** There is no `[Obsolete]` shim, so here is the whole move. Reading a member:
+
+ ```csharp
+ // was
+ JsonElement result = (JsonElement)response.Result;
+ string marker = result.GetProperty("marker").GetString();
+
+ // now — parse the bytes the node sent
+ using JsonDocument document = JsonDocument.Parse(response.RawResult.Span);
+ string marker = document.RootElement.GetProperty("marker").GetString();
+ ```
+
+ Or, for the whole member as a value: `JsonSerializer.Deserialize(response.RawResult.Span, XrplJsonOptions.Default)`. `response.RawResult.ToString()` gives the text verbatim, and allocates a UTF-16 copy each call — hold the result if you need it twice
+ * **`RawResult` is empty on any envelope you deserialize yourself.** It is populated only by `RequestManager` on the request path. Stream messages, `LedgerStreamResponse` and friends, and anything you hand to `JsonSerializer.Deserialize` come back with no frame and therefore an empty `RawResult` — by design, see the `internal Frame` note above. None of those paths read `result` before, either
+ * **behaviour shift on a hand-assembled response.** `RequestManager.Resolve` used to fall back to `JsonSerializer.Deserialize(result.ToString(), type)` for a `BaseResponse` that was built rather than parsed off the wire. Such a response now has no frame, so `DeserializeResult` substitutes `{}` and the promise **completes successfully with a defaulted object** instead of carrying the assembled values. Unreachable through the client — the only caller of `Resolve` always has a frame — but `Resolve` is public, and the old fallback is gone
+ * **the string path trades transit for retention.** Its total allocation drops, but roughly one message-length of that is no longer a transient buffer: the frame is retained for as long as the response is. Consumers holding many responses — a paged crawl kept in memory — should keep `RawResult.ToArray()` and let the frame go
+ * measured per message on the socket path — the one production uses, since `Connection` binds `OnBinaryMessage`: **92 736 → 2 432 B, a 38x reduction**. End to end on the typed path, where the removed document actually shows: **7.45x → 5.57x** of the response size, 6.32 → 4.72 MB per 889 KB response
+* **`HasNextPage()` returned false for every response, including paged ones** — it compared `Result` against `Dictionary`, which that member never was; it held a `JsonElement`. The method has no callers inside the SDK, which is how it survived. It now scans the raw result with `Utf8JsonReader` for a top-level `marker`, skipping over each non-matching member's value so a nested `marker` cannot be mistaken for the paging one
+ * its test file existed as an empty stub — a class with no `[TestClass]` and no tests, a started-and-abandoned port of xrpl.js `hasNextPage.ts`. Worse, its fully qualified name carried no `TestU`, so it would not have run under the CI filter even with tests in it. It is now `TestUHasNextPage` with ten tests, including an escaped `marker` key, near-miss keys, non-object results, and a `marker` nested inside an array of objects
+* two allocation budgets, both measured rather than guessed. The existing one is annotated with what it cannot see: it asks for `JsonElement`, so one is built either way and the figure is identical before and after (1.89x). `TestTypedResponseParsingStaysWithinItsAllocationBudget` is the one that sees the change. `TestUEnvelopeRetainsNoMoreThanTheFrame` guards retention
+* **`BaseResponse.Id` and `ErrorResponse.Request` were left as `object` at this point** — each still building a `JsonElement` with an unreturned rental, measured at **3 672 B retained per envelope carrying an `id` against 217 B without one**, on every response, and `Sugar.SubmitAndWait` catches `txnNotFound` in a polling loop where `Request` paid it repeatedly. Both are fixed further down in this same release: they became `RawId` and `RawRequest`, and the retention budget tightened from 8 192 to 6 144 bytes
+* `LONFTokenPage.PreviousTxnLgrSeq` changed from `long` to `uint?` (**breaking**) — the field is `UInt32` per `Base/Xrpl.BinaryCodec/Enums/definitions.json`, matching every other ledger entry's `PreviousTxnLgrSeq`; the prior `long` was both wider than the protocol and inconsistent with its siblings
+* `ServerState`'s `StateLedger.ReserveBase`/`ReserveInc` changed from `uint` to `uint?` (**breaking**) — the same class of defect as the rest of this section, but found on a `Methods` model rather than a ledger entry or transaction, which is exactly why it slipped past `TestUNullabilityConformance`: that test is built off `ledger_entries.macro` and the transaction formats, and `ServerState` is neither. A `server_state` response missing either field used to read back as `0`, so `Balances.GetXrpFreeBalance` computed the account/owner reserve as zero and returned a free balance inflated by the reserve it failed to subtract. `GetXrpFreeBalance` now throws `ValidationException` when either field is absent, the same shape as the existing `OwnerCount` check beside it
+* **15 more response properties were non-nullable though the node genuinely omits them — found by cross-referencing, not by `TestUNullabilityConformance`.** The same protocol field is modeled both nullable and non-nullable in different response classes in this repository; wherever that happens, at least one side is wrong. Each of the 37 non-nullable candidates that pattern turned up was checked against the actual condition guarding the field in the **rippled C++ source** (not xrpl.js — xrpl.js marks `validated` optional on fields rippled always sends unconditionally via `lookupLedger`, which would have "fixed" a healthy field and left a genuinely conditional one alone). 28 of the 37 are unconditional in rippled and stay as they are; 9 are conditional and are now `?`. A later pass over the same ledger-subscribe paths found 6 more the cross-reference had not surfaced (`fee_ref`, `fee_base`, `ledger_time`, `txn_count`, `ServerFeatures.Validated`), which is where the table's count of 15 comes from - 16 declarations, since `Closed` is declared on both `BaseLedgerEntity` and the `IBaseLedgerEntity` interface beside it:
+ * `LOBaseLedger.LedgerIndex` — unconditional for the dedicated `ledger_closed` command, but the same property is inherited by `LOLedger` for the general `ledger` command, where rippled's shared `lookupLedger` sets `ledger_index` only when the resolved ledger is closed; an open/current-ledger request gets `ledger_current_index` instead and omits this member entirely
+ * `HashOrTransaction.LedgerTransaction.Validated` — structurally absent on a `ledger` response's expanded transactions when the request used API v1; rippled's `LedgerToJson.cpp` only writes it inside the `apiVersion > 1` branch
+ * `BaseLedgerEntity.Closed` (and `IBaseLedgerEntity.Closed`) — omitted from the nested `ledger` object for a non-binary `ledger` response when the ledger is open and `full: true` was requested
+ * `NoRippleCheck.LedgerCurrentIndex` — same `lookupLedger` gate as `LOBaseLedger.LedgerIndex` above: absent whenever `noripple_check` resolves against a closed/validated ledger
+ * `ServerFeatures.LedgerIndex` — worse than merely conditional: rippled's `feature` handler never calls `lookupLedger` and never writes `ledger_index` at all, so this member was fabricated as `0` on **every** response, not just some. The custom `ServerFeaturesConverter` had its own `: 0` default baked into the read path, a second copy of the same defect the attribute-based fix elsewhere in this codebase does not reach
+ * `LedgerStreamResponse.LedgerIndex`/`ReserveBase`/`ReserveInc`/`LedgerTime`/`FeeBase` — this class models the `subscribe` command's own synchronous reply when the client subscribes to the `ledger` stream (`NetworkOpsImp::subLedger`), a different rippled code path from the async `ledgerClosed` push that `LedgerStream` models; `subLedger` gates the whole block on `ledgerMaster_.getValidatedLedger()` returning non-null, so a node with no validated ledger yet omits every one of them from the initial reply even though the async push always includes them
+ * `LedgerStreamResponse.TxnCount` — not conditional but absent outright: `subLedger` emits no `txn_count` on this path at all, which this type's own summary already stated ("does NOT include the 'type' nor 'txn_count' fields") while the property fabricated `txn_count: 0` into every reply anyway
+ * **`LedgerStream.FeeRef` and `LedgerStreamResponse.FeeRef` — the everyday case, not an edge case.** rippled guards `fee_ref` with `if (!rules().enabled(featureXRPFees))` on both paths. `XRPFees` has been active on mainnet since 2023, so no current node sends the member at all: every `ledgerClosed` event a wallet round-tripped gained a `fee_ref: 0` the node never wrote. On the `subLedger` path it is conditional twice over — inside the validated-ledger gate *and* behind the amendment check
+ * `ServerFeatures.Validated` — the same `feature`-handler fact recorded one line above for `LedgerIndex`: the handler writes no `validated` either, and `ServerFeaturesConverter` carried the matching `&& v.GetBoolean()` in its read path, which collapses "absent" and "false" into `false`. Its `Write` throws, so nothing was fabricated on output here — this one is about reading the node honestly, not about round-trip
+ * `ValidationStream.LedgerIndex` — rippled's `pubValidation` sets it only when the underlying `STValidation` carries the optional `sfLedgerSequence` field
+ * `TestUNodeMayOmitProtocolFields` proves it by round-tripping the exact node-omission shape for each one: deserialize, assert the property is `null` (not a fabricated `0`/`false`), re-serialize, assert the JSON key is absent. Proven to fail, not merely pass, twice over: reverting `HashOrTransaction.LedgerTransaction.Validated` alone turned 2 of its 11 tests red, and reverting `FeeRef` turned 4 of 13 red
+ * Everything else the same audit checked stays as it is, each for a rippled-sourced reason: `AccountOffers`' per-offer `seq`, `account_tx`'s top-level `validated`/`limit` and each transaction's `validated`, `account_info`/`account_currencies`/`account_nfts`/`account_objects`'s `validated` (all `lookupLedger`-unconditional), `AccountInfo.AccountQueueTransaction.AuthChange`, `Fee.LedgerCurrentIndex`/`Drops.BaseFee`, `PathFindResponse`/`PathFindStream`'s `full_reply`, `Subscribe.LedgerStream`'s `ledger_index`/`reserve_base`/`reserve_inc` (the async push, unconditional — contrast with `LedgerStreamResponse` above), `BookChangesStream.LedgerIndex`, `ManifestStream.Seq`, `OrderBookStream`/`TransactionStream`'s `validated`, and `Transactions.BookOffers.Offer.PreviousTxnLgrSeq` (`SoeRequired` on every real `ltOFFER`). The `seq` sub-fields on `LedgerEntry`'s `PermissionedDomainQuery`/`OfferQuery`/`EscrowQuery` are request parameters rippled rejects as `malformedRequest` when absent, not response fields, so they were never candidates for this fix despite surfacing in the same cross-reference
+* **Stream events could not get at the bytes a node sent either — the named remainder of the raw-response work, called out in review as "repeat level 0 for the stream path" and left standing across three later levels.** A query response gets `RawResult` because `RequestManager.HandleResponse(byte[])` keeps the frame and pairs it with the envelope through `AttachFrame`. A stream message — `transaction`, `ledgerClosed`, and the rest — never went through that: `Connection.EnqueueStreamMessage` queued a `string`, already decoded from UTF-8 and with no frame behind it to point into. For a wallet, this is the one path that matters most: transactions reach it as a stream, not as a query response.
+ * `BaseStream` — the base of every stream event except `LedgerStream` and `ErrorResponse` before this change — gained `Raw` and an internal `AttachFrame(byte[])`, mirroring `BaseResponse`. Unlike a query response, a stream message carries no `result` envelope to slice a member out of: the frame passed to `AttachFrame` *is* the event, so `Raw` spans the whole of it. `JsonSlice` gained `OfDocument(byte[])` to compute those bounds - the same `TokenStartIndex`/`Skip()`/`BytesConsumed` technique `JsonSliceConverter` uses for a member, run once for the top-level value instead
+ * `LedgerStream` now extends `BaseStream` rather than declaring its own `Type` field, which is what lets it carry `Raw` the same way every other stream event does. Found in passing: that field had a `[JsonPropertyName]`/`[JsonConverter]` pair on it but no `[JsonInclude]`, and this library does not set `IncludeFields` - System.Text.Json does not serialize a public field without one or the other, so the field was never actually assigned by deserialization. Harmless in practice, since `LedgerStream` is only ever produced from a `ledgerClosed` message, but not by design; the inherited property fixes it as a side effect. Source-compatible - a field and an auto-property read and assign the same way - so nothing using `LedgerStream.Type` needs to change, but **not binary-compatible**: an assembly compiled against the previous package fails at runtime with `MissingFieldException` until it is rebuilt.
+ * **`TransactionStream` gained `RawTransaction`** — the transaction alone, not the whole event `Raw` carries, which is the one thing a wallet displaying a transaction for signing actually asks for. `Transaction` and its private API v1 alias already claim `tx_json` and `transaction` as JSON property names, and System.Text.Json rejects a second member bound to a name another member already owns - so unlike every other slice in this codebase, `RawTransaction` cannot be filled through a converter-backed property. `JsonSlice` gained `FindTopLevelMember(byte[], ReadOnlySpan)` for exactly this case: it scans the frame directly, the same way `RawJson.HasTopLevelProperty` checks for presence, and `TransactionStream.AttachFrame` uses it to try `tx_json` first and fall back to `transaction`
+ * **`Connection`'s stream pipeline now carries the frame, not text** (not breaking: every member listed here is `private`, and no public signature changes type). `_streamMessageChannel` is `Channel`, not `Channel`; `ProcessStreamMessageAsync`, `ProcessStreamMessageFireAndForgetAsync`, `EnqueueStreamMessage` and `NotifyStreamProcessingErrorAsync` all take the frame. `OnMessage(string)` — still public, still how every existing test feeds a message in by hand — builds a frame with `Encoding.UTF8.GetBytes` exactly the way `RequestManager.HandleResponse(string)` already does for the same reason, so nothing that called it needed to change. The binary path (`OnBinaryMessage`, what production actually uses) reuses the frame the socket produced instead of encoding a second copy: strictly less allocation than before, since the channel used to hold a UTF-16 string built from that same frame. `OnWarning`/`OnServerWarning`/`OnError`, which still take a string, materialize text lazily off the frame exactly as they did before this change - only later, and only when something is listening
+ * the channel's bound is unchanged (10 000, `DropOldest`) and was not the concern here: a `byte[]` frame is roughly half the size of the UTF-16 `string` it replaces in the same slot, so the channel's worst case shrank, not grew. What was checked and confirmed instead: attaching a frame to a stream event costs nothing beyond the shared reference - `TestUTransactionStreamAttachFrameRetainsNoMoreThanTheFrame` measured **0 B marginal** per instance for `AttachFrame` over 2 000 samples (budget 300 B), against a 744 B frame that a copy-per-instance regression would show up against almost in full
+ * `OnMessage(null)` still cannot throw out of the entry point - it used to be routed to the stream processor as a stream message and reported through `OnError` as `badMessage` rather than raised, and carrying bytes instead of text must not turn that into a throw from `Encoding.UTF8.GetBytes(null)` at the frame-building step itself. `TestNullMessageIsStillReportedThroughOnError` pins it
+ * `TestUStreamRawJson` covers the pipeline end to end - `OnMessage` through the channel to `AttachFrame` - rather than only the model in isolation: a `ledgerClosed` message with a field (`network_id`) no property models survives to `Raw` byte for byte and — since `BaseStream` gained `[JsonExtensionData]` — reaches a re-serialization of the typed `LedgerStream` as well, which is what that assertion was flipped to prove, and `RawTransaction` is checked against both the API v1 and v2 envelope, independently, through `JsonDocument.GetRawText()` rather than by re-deriving the same offsets the code under test computes
+* **`JsonSlice.FindTopLevelMember` returned the first occurrence of a duplicate top-level member; `JsonSerializer` returns the last** — rippled does not send a frame with two top-level `tx_json` members, but nothing between the socket and this code rules one out (an intermediate proxy, a compromised link). Such a frame left `TransactionStream.RawTransaction` pointing at the first occurrence while the deserializer-fed `Transaction` reflected the last, matching `JsonSerializer`'s own last-value-wins behaviour for a POCO property fed by a duplicate JSON member (the default unless `JsonSerializerOptions.AllowDuplicateProperties = false`, which `XrplJsonOptions.Default` does not set) — a wallet would show a person one transaction and sign a different one. The scan now continues to `EndObject` and keeps the last match instead of returning on the first, matching the deserializer it feeds `RawTransaction` alongside
+* **`FindTopLevelMember`/`RawJson.HasTopLevelProperty` matched case-sensitively while `XrplJsonOptions.Default` sets `PropertyNameCaseInsensitive = true`** — a frame spelling the member `"TX_JSON"` populated the typed `TransactionStream.Transaction` through the case-insensitive deserializer while `RawTransaction` came back empty, since `Utf8JsonReader.ValueTextEquals` has no case-insensitive form. Both now decode the property name through `Utf8JsonReader.GetString()` (which also unescapes it, same as before) and compare with `StringComparison.OrdinalIgnoreCase`, matching the deserializer's own rule. `RawJson.HasTopLevelProperty` still returns on the first match rather than scanning to the end — presence does not depend on which occurrence is meant, unlike `FindTopLevelMember`'s value lookup above
+* **`BaseStream.Type` is `ResponseStreamType?` now** (**breaking**) — the same class of defect as `LedgerEntryType` earlier in this release, found on review of the stream work directly above. `LedgerStream()`'s constructor stamped `Type = ResponseStreamType.ledgerClosed` unconditionally, so an instance built by hand (rather than through the deserializer) reported a type it was never actually given; separately, the non-nullable enum's default of `ResponseStreamType.UNKNOWN` (0) meant `JsonSerializer.Serialize(new TransactionStream())` wrote back the literal member `"type":"UNKNOWN"` for an event that carried no type at all. The constructor is gone — deserialization off a real message already populates `Type` correctly, the same as every other property on these classes — and absence now round-trips as absence (`XrplJsonOptions.Default` omits a null member on write). Does not touch stream dispatch: `Connection.ProcessStreamMessageAsync` decides which typed class to build from `BaseResponse.Type` — an unrelated `string` property, deserialized separately from the raw JSON `"type"` member before the typed `LedgerStream`/`TransactionStream`/etc. instance exists — not from `BaseStream.Type`
+
+* **The fabrication audit had no mirror image, so losses stayed.** Everything above removes members the node never sent. The reverse — members the node *did* send, dropped on the way to a caller — was only closed for the shapes the fidelity corpus happened to have fixtures for. Closed the rest:
+ * **stream events dropped fields rippled writes unconditionally.** `network_id` (`NetworkOpsImp::pubLedger` and `subLedger`, both paths), `ctid` (`transJson`, on every validated transaction) and the `account_history_tx_index`/`_boundary`/`_tx_first` trio (`account_history` subscriptions) had no property on any stream model and no capture to fall into. `BaseStream` now carries `[JsonExtensionData]`, mirroring `BaseLedgerEntry`/`BaseTransactionResponse`/`BaseMethodResult` for their own families, so every stream type picks it up. `LedgerStreamResponse` declares its own, since it descends from `BaseResponse`, whose `id`/`result` members are byte-range slices rather than parsed values. Note that nothing routes through that type today — `Subscribe` returns `XrplResponse