diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 64ab526b..70b66476 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -2,8 +2,7 @@ language: en-US reviews: profile: chill - review_status: true - + review_status: false auto_review: # Automatic review is off: reviews are requested on demand with # "@coderabbitai review" in a PR comment. The settings below still apply diff --git a/CHANGES.md b/CHANGES.md index ad9e1f5c..288538ea 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,32 @@ # Changes +## 10.12.0.0 08/16/2026 + +* **`Request(Dictionary)` never delivered the API version, so one client spoke two protocol versions** (**breaking**) — it stamped the version under `nameof(ApiVersion)`, literally `"ApiVersion"`. A dictionary is serialized verbatim, and rippled knows only `api_version`: it ignores unknown fields and answers on its default, API v1. Measured on mainnet, the three spellings are not equivalent — `api_version: 2` returns the v2 shape, while `"ApiVersion": 2` and no version field at all both return v1. So `client.AccountInfo(…)` went out as v2 while `client.Request(new Dictionary { ["command"] = "account_info" })` on the *same client* went out as v1, and response shapes differed between the two with nothing to signal it. The typed path was never affected: `BaseRequest.ApiVersion` carries `[JsonPropertyName("api_version")]`. + * the key is now the wire name, and a version the caller put in the dictionary themselves is still respected. The junk `"ApiVersion"` field no longer rides along on every request + * **breaking:** callers of the untyped path move from API v1 to whatever `ApiVersion` says, which defaults to 2 — response shapes change under code that did not change. This is the fix, not a side effect: the previous behaviour ignored the setting entirely. Callers who want v1 can put `["api_version"] = 1` in the dictionary or set `ApiVersion` on the client + * `TestURequestApiVersion` reads what the client actually puts on the wire through a request-capturing WebSocket server — a field the node ignores cannot be seen from the response, which is how this survived. It pins the wire name on the untyped path, that an explicit `api_version` is not overwritten, and that both request paths of one client carry the same version. `WebSocketTestServerBase` gained the client-frame reader that `PagedResponseServer` had kept private, rather than a third copy of it +* **`TransactionStream` re-parsed the transaction on every read of it, and lost the hash under API v1** (**breaking**) — the same defect `TransactionSummary` was fixed for in 10.9.1.0, left standing on the stream side. `Transaction` was an expression-bodied property over two `object` members holding `JsonElement`s: `JsonSerializer.Deserialize((TransactionJson ?? Proposed).ToString(), …)`. Three things wrong with that one line, on the busiest path the client has — every transaction of a `transactions` subscription: + * **the transaction was rendered back to a string and parsed a second time.** It was already parsed: `TransactionJson`/`Proposed` are `object`, which System.Text.Json fills with a self-contained `JsonElement`. Same round trip as the one removed from `RequestManager.Resolve` below + * **nothing was cached**, so the expression ran again on every access. Measured over 300 real mainnet stream messages: one read cost 4.94 KB (API v1) / 3.96 KB (v2), three reads cost exactly three times that — 14.82 KB and 11.89 KB. A consumer reading `TransactionType` and then `Hash` paid twice, and nothing in the property's signature said so + * **the hash was unreachable under API v1.** rippled reports it at the top level under v2 but only inside the envelope under v1, and `Hash` was mapped to the top-level field alone, so `tx.Hash` was always `null` on v1 — which is what left the `Blazor-WebAssembly` demo printing no hash, since it requests `"ApiVersion": 1`. Verified against mainnet in both directions + * a message carrying neither envelope threw `NullReferenceException` straight out of the property + + `TransactionStream` now follows `TransactionSummary`: `Transaction` is typed `TransactionResponse` and mapped to `tx_json`, a private set-only `TransactionV1` alias catches the API v1 `transaction` envelope, and `Hash` falls back to the envelope. The transaction is deserialized **once, with the message that carries it** — there is no second parse left to cache, and reading the property back is a field read. `ledger_index` and `ledger_hash` needed no fallback: rippled reports both at the top level in either version, which the captures confirm. + * measured over the same 300 messages per version, allocation per message for the whole consumer flow — deserialize the message, then read the transaction off it: **16.44 → 13.75 KB** at one read and **26.32 → 13.75 KB** at three (API v1); **15.24 → 13.07 KB** and **23.17 → 13.07 KB** (API v2). The figure no longer moves with the number of reads at all, which is the point. Timings did not separate reliably on the measuring machine and are not quoted + * the trade-off, stated plainly: deserializing the message alone went **up**, 11.50 → 13.75 KB (v1), because the transaction is now materialized eagerly instead of being left as a lazy `JsonElement`. A consumer that never touches `Transaction` pays about 2.25 KB more per message; one that touches it once or more pays 2.7–12.6 KB less + * **breaking:** the public `object` properties `TransactionJson` and `Proposed` are gone — they existed only as raw envelopes for the getter to re-parse, and there is nothing left to re-parse. `Transaction` keeps its name and type and gains a setter. Consistent with the removal of `Path.TypeHex` in 10.11.0.0, no `[Obsolete]` grace period + * `TestUTransactionStreamEnvelope` pins both envelopes, the hash under both versions, the message carrying neither, and that repeated reads allocate nothing and hand back the same instance +* **Every response was parsed twice and copied to UTF-16 twice** — the cost of reading a response, measured rather than reasoned about. `RequestManager.Resolve` did `JsonSerializer.Deserialize(response.Result?.ToString() ?? "{}", taskInfo.Type, ...)`. `BaseResponse.Result` is typed `object`, which System.Text.Json fills with a `JsonElement` that already owns a private copy of the `result` bytes — so `.ToString()` rendered that element back into a UTF-16 string and the serializer parsed the string a second time. On a `ledger_data` page at `limit=2048` (~1 MB) the four stages measured, per response, at: 1.97 MB for the UTF-16 copy of the message, 1.68 MB for the document built over it, 1.97 MB for the UTF-16 copy of the `result`, 1.68 MB for the second document — **7.30 MB, 7.42x the response size**, all four allocations past the 85 KB large-object threshold. Both halves are now gone: + * `DeserializeResult` works off the parsed node: `element.Deserialize(type, options)` for a typed model, and the element itself when the request asked for `JsonElement` or `object`, which is what a consumer that needs the raw ledger objects asks for (the typed `LOLedgerData.State` drops unknown fields). A `BaseResponse` assembled by hand rather than parsed off the wire keeps the old string path. Behaviour is otherwise unchanged, including a missing or JSON-`null` `result`, which still yields what deserializing `"{}"` yielded + * the socket path carries the frame as it arrived. `Connection` binds `OnBinaryMessage` instead of `OnMessageReceived`, `IsLikelyResponse` and `RequestManager.HandleResponse` have `ReadOnlySpan` overloads, and the UTF-16 string is materialized — once, lazily — only for what genuinely needs text: stream messages and the `OnWarning`/`OnServerWarning`/`OnError` callbacks. The `string` overloads stay for `Connection.OnMessage(string)` and for external callers + * the warning callbacks no longer pay for listeners that are not there. rippled attaches `warning`/`warnings` to responses under load and on a reporting-mode server, and the dispatch built the UTF-16 text for them before checking whether `OnWarning`/`OnServerWarning` were subscribed — on such a server that is the removed allocation, back on every page. Measured with warnings on all 20 pages and nothing subscribed: 4.28x → 2.08x + * the failure report survives the failure. A response that will not parse is most often a heap that has just run out, and materializing the message for `OnError` is then the largest allocation left on the path — if it throws, the notification is lost inside the handler and the consumer sees silence. The text is now built only when a handler is attached, and an `OutOfMemoryException` while building it falls back to a literal placeholder so the classification still goes out. `Connection.OnMessage(null)` also keeps its old route through `OnError` instead of throwing `ArgumentNullException` out of the entry point + * measured end to end against a local WebSocket server, 600 `ledger_data` pages of ~1 MB: **8.32 → 2.68 MB allocated per response** (8.46x → 2.72x the payload), 11.49 → 7.96 ms per response, 87 → 126 responses/s, peak managed heap 42.4 → 20.8 MB, peak LOH 39.2 → 17.3 MB, peak working set 203.4 → 71.8 MB. Under a lowered `DOTNET_GCHeapHardLimit` the pre-fix path reproduced the production failure exactly — `XrplException: Failed to deserialize response for request : Exception of type 'System.OutOfMemoryException' was thrown`, with `JsonElement.ToString()` at the top of the inner stack — at a ceiling the fixed path completes 15/15 pages under + * the win is not specific to `ledger_data` or to `JsonElement`: the second parse was on the path of every command. The repo's own `BenchmarkLedgerDataCrawl`, which goes through `Request` → `Dictionary`, drops from 22.9 to 14.8 MiB allocated per 2 MiB page (11.4x → 7.4x) with LOH ending at 38.2 instead of 115.4 MiB — it stays above the `JsonElement` figure because building a `Dictionary` boxes every value, which this change does not address + * `TestUResponseParsing` pins the behaviour that had to survive — the untyped node handed through is self-contained and readable after a forced gen2 collection, a typed model deserializes to the same values, the `string` and UTF-8 overloads agree, a missing `result` still completes, an `error` status still rejects with the parsed `ErrorResponse` attached, a null message does not throw out of the entry point — and holds two allocation budgets at 4x the response size. The first measures `RequestManager` alone, per thread so the class-parallel run cannot perturb it (1.89x now). The second runs 20 pages through `Connection` over a real socket, because nothing else in the suite can see *which* overload the client picks: it reads the process-wide counter and is therefore kept out of the parallel pass, and it separates the two paths with room on both sides — 2.18x as bound, 4.84x with the string callback bound instead. `PagedResponseServer` reuses one response frame per connection and rewrites the id in place so the server contributes nothing to what the client is measured on +* **`error` responses were deserialized a third time** — the `status == "error"` branch of `HandleResponse` re-parsed the whole message into an `ErrorResponse` inside a `try`/`catch` that swallowed everything, to build the exception's `Response`. The message had already been deserialized into an `ErrorResponse` at the top of the same method; the second parse only produced an equal copy, and on a large error payload it was a second large-object allocation on a path that is already failing + ## 10.11.1.0 08/13/2026 * **Fix infinite recursion in `LONFTokenConverter.Write` — the metadata of an NFT transaction could not be serialized at all** — regression introduced in 10.3.0.0 with the `Newtonsoft.Json` → `System.Text.Json` migration; affects every release from 10.3.0.0 on. `JsonSerializer.Serialize(tx.Meta)` threw `JsonException: A possible object cycle was detected` for any transaction whose `AffectedNodes` contain an `NFTokenPage`, which is every `NFTokenMint`, `NFTokenBurn`, `NFTokenAcceptOffer` and `NFTokenModify` that touched a page. Verified against mainnet on all six NFT transaction types — the four above failed, `NFTokenCreateOffer` and `NFTokenCancelOffer` (no page in their metadata) went through: diff --git a/Tests/Xrpl.Tests/Client/BenchmarkLedgerDataCrawl.cs b/Tests/Xrpl.Tests/Client/BenchmarkLedgerDataCrawl.cs index dc86527a..18521c80 100644 --- a/Tests/Xrpl.Tests/Client/BenchmarkLedgerDataCrawl.cs +++ b/Tests/Xrpl.Tests/Client/BenchmarkLedgerDataCrawl.cs @@ -5,7 +5,11 @@ using System.Diagnostics; using System.Threading.Tasks; +using System.Text.Json; + using Xrpl.Client; +using Xrpl.Models.Common; +using Xrpl.Models.Methods; namespace Xrpl.Tests.ClientLib { @@ -112,6 +116,78 @@ public async Task BenchmarkSequentialPaging() Console.WriteLine("decile profile (ms/page): " + string.Join(" | ", DecileProfile(pageMs))); } + /// + /// Same crawl through GRequest<JsonElement, …> — the path a consumer takes when + /// it needs the raw ledger objects, because the typed LOLedgerData.State drops + /// fields the models do not know. This is where the response path's own cost shows up + /// undiluted: nothing is materialized into a model, so what is measured is receive, + /// route and parse. + /// + [TestMethod] + public async Task BenchmarkSequentialPagingUntyped() + { + int pages = EnvInt("CRAWL_PAGES", 2000); + int payloadBytes = EnvInt("CRAWL_PAYLOAD_BYTES", 2 * 1024 * 1024); + int fragments = EnvInt("CRAWL_FRAGMENTS", 32); + + using PagedResponseServer server = new PagedResponseServer(payloadBytes, fragments); + using XrplClient client = new XrplClient(server.Url); + + await client.Connect().ConfigureAwait(false); + await RequestUntypedPageAsync(client).ConfigureAwait(false); + + GC.Collect(2, GCCollectionMode.Forced, blocking: true); + GC.WaitForPendingFinalizers(); + GC.Collect(2, GCCollectionMode.Forced, blocking: true); + + long allocatedBefore = GC.GetTotalAllocatedBytes(precise: true); + int gen2Before = GC.CollectionCount(2); + long lohBefore = LohBytes(); + long startTicks = Stopwatch.GetTimestamp(); + + for (int i = 0; i < pages; i++) + { + await RequestUntypedPageAsync(client).ConfigureAwait(false); + } + + double totalSeconds = (Stopwatch.GetTimestamp() - startTicks) / (double)Stopwatch.Frequency; + long allocated = GC.GetTotalAllocatedBytes(precise: true) - allocatedBefore; + long lohAfter = LohBytes(); + + await client.Disconnect().ConfigureAwait(false); + + Console.WriteLine("=== ledger_data crawl benchmark (untyped JsonElement result) ==="); + Console.WriteLine($"pages : {pages}"); + Console.WriteLine($"payload : {payloadBytes / 1024.0 / 1024.0:F2} MiB"); + Console.WriteLine($"total time : {totalSeconds:F2} s ({pages / totalSeconds:F2} pages/s)"); + Console.WriteLine($"allocated per page: {allocated / (double)pages / 1024.0 / 1024.0:F1} MiB " + + $"({allocated / (double)pages / payloadBytes:F1}x payload)"); + Console.WriteLine($"gen2 collections : {GC.CollectionCount(2) - gen2Before}"); + Console.WriteLine($"LOH size : {lohBefore / 1024.0 / 1024.0:F1} -> {lohAfter / 1024.0 / 1024.0:F1} MiB"); + } + + private static async Task RequestUntypedPageAsync(XrplClient client) + { + JsonElement result = await client + .GRequest(new LedgerDataRequest + { + LedgerIndex = new LedgerIndex(96000000), + Binary = true, + Limit = 2048 + }) + .ConfigureAwait(false); + + if (result.ValueKind != JsonValueKind.Object || !result.TryGetProperty("state", out JsonElement state)) + { + throw new InvalidOperationException("empty ledger_data response"); + } + + if (state.GetArrayLength() == 0) + { + throw new InvalidOperationException("ledger_data page carried no objects"); + } + } + private static async Task RequestPageAsync(XrplClient client) { Dictionary request = new Dictionary diff --git a/Tests/Xrpl.Tests/Client/PagedResponseServer.cs b/Tests/Xrpl.Tests/Client/PagedResponseServer.cs index ce23bd38..d53fc90c 100644 --- a/Tests/Xrpl.Tests/Client/PagedResponseServer.cs +++ b/Tests/Xrpl.Tests/Client/PagedResponseServer.cs @@ -1,5 +1,4 @@ -using System; -using System.Buffers.Binary; +using System; using System.Net.Sockets; using System.Text; using System.Threading; @@ -15,16 +14,25 @@ namespace Xrpl.Tests /// internal sealed class PagedResponseServer : WebSocketTestServerBase { + /// The id `Connection` sends: a quoted GUID in `D` format, always 38 characters. + private const int QuotedGuidLength = 38; + private readonly int _fragments; private readonly string _resultBody; + private readonly bool _withWarnings; private int _served; /// Target size of each response, in bytes. /// Number of WebSocket frames each response is split into. - public PagedResponseServer(int approximatePayloadBytes, int fragments) + /// + /// Attach warning and warnings to every response, the way rippled does under + /// load and on a reporting-mode server. + /// + public PagedResponseServer(int approximatePayloadBytes, int fragments, bool withWarnings = false) { _fragments = Math.Max(1, fragments); _resultBody = BuildResultBody(approximatePayloadBytes); + _withWarnings = withWarnings; StartAccepting(); } @@ -79,6 +87,9 @@ private static void AppendHex(StringBuilder builder, int seed, int length) protected override async Task ServeAsync(NetworkStream stream) { + // One frame buffer per connection, so two clients cannot rewrite each other's id. + byte[]? frame = null; + while (!Token.IsCancellationRequested) { string? message = await ReadTextFrameAsync(stream).ConfigureAwait(false); @@ -88,15 +99,46 @@ protected override async Task ServeAsync(NetworkStream stream) } string id = ExtractId(message); - string envelope = "{\"id\":" + id + ",\"status\":\"success\",\"type\":\"response\",\"result\":" + - _resultBody + "}"; - await WriteFragmentedMessageAsync(stream, Encoding.UTF8.GetBytes(envelope), _fragments) - .ConfigureAwait(false); + await WriteFragmentedMessageAsync(stream, BuildFrame(id, ref frame), _fragments).ConfigureAwait(false); Interlocked.Increment(ref _served); } } + /// + /// Builds the response frame for . The usual case - a quoted GUID, the + /// only form Connection sends - reuses one buffer and rewrites the id in place, so + /// the server contributes nothing to what a caller measures on the client side. Anything + /// else falls back to assembling the envelope. + /// + private byte[] BuildFrame(string id, ref byte[]? reusable) + { + if (id.Length != QuotedGuidLength) + { + return Encoding.UTF8.GetBytes(Envelope(id)); + } + + reusable ??= Encoding.UTF8.GetBytes(Envelope("\"" + new string('0', QuotedGuidLength - 2) + "\"")); + + int idOffset = "{\"id\":".Length; + for (int i = 0; i < QuotedGuidLength; i++) + { + reusable[idOffset + i] = (byte)id[i]; + } + + return reusable; + } + + private string Envelope(string id) + { + string warnings = _withWarnings + ? ",\"warning\":\"load\",\"warnings\":[{\"id\":1001,\"message\":\"This is a reporting server.\"}]" + : string.Empty; + + return "{\"id\":" + id + ",\"status\":\"success\",\"type\":\"response\",\"result\":" + _resultBody + + warnings + "}"; + } + /// Pulls the JSON string value of the request's "id" property. private static string ExtractId(string message) { @@ -133,75 +175,5 @@ private static string ExtractId(string message) return message.Substring(start, stop - start).Trim(); } - /// - /// Reads one client frame. Returns the decoded text of the first text frame seen, or null - /// once the peer closes. Control frames other than Close are skipped. - /// - private async Task ReadTextFrameAsync(NetworkStream stream) - { - while (true) - { - byte[] head = new byte[2]; - if (!await ReadExactAsync(stream, head, 2).ConfigureAwait(false)) - { - return null; - } - - int opcode = head[0] & 0x0F; - bool masked = (head[1] & 0x80) != 0; - long length = head[1] & 0x7F; - - if (length == 126) - { - byte[] extended = new byte[2]; - if (!await ReadExactAsync(stream, extended, 2).ConfigureAwait(false)) - { - return null; - } - - length = BinaryPrimitives.ReadUInt16BigEndian(extended); - } - else if (length == 127) - { - byte[] extended = new byte[8]; - if (!await ReadExactAsync(stream, extended, 8).ConfigureAwait(false)) - { - return null; - } - - length = (long)BinaryPrimitives.ReadUInt64BigEndian(extended); - } - - byte[] mask = new byte[4]; - if (masked && !await ReadExactAsync(stream, mask, 4).ConfigureAwait(false)) - { - return null; - } - - byte[] payload = new byte[length]; - if (length > 0 && !await ReadExactAsync(stream, payload, (int)length).ConfigureAwait(false)) - { - return null; - } - - if (masked) - { - for (int i = 0; i < payload.Length; i++) - { - payload[i] ^= mask[i % 4]; - } - } - - if (opcode == 0x8) - { - return null; - } - - if (opcode == 0x1 || opcode == 0x2) - { - return Encoding.UTF8.GetString(payload); - } - } - } } } diff --git a/Tests/Xrpl.Tests/Client/RequestCapturingServer.cs b/Tests/Xrpl.Tests/Client/RequestCapturingServer.cs new file mode 100644 index 00000000..580fbdea --- /dev/null +++ b/Tests/Xrpl.Tests/Client/RequestCapturingServer.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Xrpl.Tests +{ + /// + /// WebSocket server that records the raw text of every request it receives and answers each + /// one with an empty success. Lets a test assert on what the client actually put on the wire, + /// which is the only way to see fields the node would silently ignore. + /// + internal sealed class RequestCapturingServer : WebSocketTestServerBase + { + private readonly ConcurrentQueue _requests = new ConcurrentQueue(); + + public RequestCapturingServer() + { + StartAccepting(); + } + + /// Every request seen so far, in arrival order. + public IReadOnlyCollection Requests => _requests; + + /// The last request whose command is . + public string LastRequestFor(string command) + { + string found = null; + foreach (string request in _requests) + { + using JsonDocument document = JsonDocument.Parse(request); + if (document.RootElement.TryGetProperty("command", out JsonElement value) && + value.ValueKind == JsonValueKind.String && + value.GetString() == command) + { + found = request; + } + } + + return found; + } + + protected override async Task ServeAsync(NetworkStream stream) + { + while (!Token.IsCancellationRequested) + { + string request = await ReadTextFrameAsync(stream).ConfigureAwait(false); + if (request == null) + { + return; + } + + _requests.Enqueue(request); + + string id = ExtractId(request); + byte[] response = Encoding.UTF8.GetBytes( + "{\"id\":" + id + ",\"status\":\"success\",\"type\":\"response\",\"result\":{}}"); + + await WriteFragmentedMessageAsync(stream, response, fragments: 1).ConfigureAwait(false); + } + } + + /// Echoes the request's id back verbatim, quotes included. + private static string ExtractId(string request) + { + using JsonDocument document = JsonDocument.Parse(request); + if (!document.RootElement.TryGetProperty("id", out JsonElement id)) + { + return "\"0\""; + } + + return id.ValueKind == JsonValueKind.String ? "\"" + id.GetString() + "\"" : id.ToString(); + } + } +} diff --git a/Tests/Xrpl.Tests/Client/TestURequestApiVersion.cs b/Tests/Xrpl.Tests/Client/TestURequestApiVersion.cs new file mode 100644 index 00000000..13ce6edf --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestURequestApiVersion.cs @@ -0,0 +1,112 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; + +using Xrpl.Client; +using Xrpl.Models.Methods; + +namespace Xrpl.Tests.ClientLib +{ + /// + /// The untyped + /// used to stamp the version under nameof(ApiVersion) — literally "ApiVersion". + /// rippled knows only api_version, ignores anything else and falls back to API v1, so + /// the client's configured version never reached the node and the two request paths of one + /// client spoke different protocol versions. These tests read what actually goes on the wire, + /// because a field the node ignores is invisible from the response. + /// + /// + /// The untyped calls below deliberately use a different command from the typed ones: + /// issues a typed server_info of its own, so matching + /// on that command alone cannot tell the two paths apart. + /// + [TestClass] + public class TestURequestApiVersion + { + private const string UntypedCommand = "ledger_current"; + + private static uint? ApiVersionOf(string request) + { + using JsonDocument document = JsonDocument.Parse(request); + return document.RootElement.TryGetProperty("api_version", out JsonElement version) + ? version.GetUInt32() + : null; + } + + private static void AssertNoMemberNameOnTheWire(string request) + { + using JsonDocument document = JsonDocument.Parse(request); + Assert.IsFalse( + document.RootElement.TryGetProperty("ApiVersion", out _), + $"the C# member name must not reach the wire, rippled ignores it: {request}"); + } + + [TestMethod] + public async Task TestUntypedRequestSendsTheWireFieldName() + { + using RequestCapturingServer server = new RequestCapturingServer(); + using XrplClient client = new XrplClient(server.Url, new XrplClient.ClientOptions { ApiVersion = 2 }); + + await client.Connect().ConfigureAwait(false); + await client.Request(new Dictionary { ["command"] = UntypedCommand }).ConfigureAwait(false); + await client.Disconnect().ConfigureAwait(false); + + string sent = server.LastRequestFor(UntypedCommand); + Assert.IsNotNull(sent, "the server saw no untyped request"); + + Assert.AreEqual(2u, ApiVersionOf(sent), $"the untyped path dropped the client's version: {sent}"); + AssertNoMemberNameOnTheWire(sent); + } + + [TestMethod] + public async Task TestUntypedRequestKeepsAVersionTheCallerSet() + { + using RequestCapturingServer server = new RequestCapturingServer(); + using XrplClient client = new XrplClient(server.Url, new XrplClient.ClientOptions { ApiVersion = 2 }); + + await client.Connect().ConfigureAwait(false); + await client.Request(new Dictionary + { + ["command"] = UntypedCommand, + ["api_version"] = 1 + }).ConfigureAwait(false); + await client.Disconnect().ConfigureAwait(false); + + string sent = server.LastRequestFor(UntypedCommand); + Assert.IsNotNull(sent); + + Assert.AreEqual(1u, ApiVersionOf(sent), $"an explicit api_version must not be overwritten: {sent}"); + AssertNoMemberNameOnTheWire(sent); + } + + /// + /// The typed path was always correct — carries + /// [JsonPropertyName("api_version")]. Both paths are pinned together here because + /// the defect was precisely that one client spoke two protocol versions depending on which + /// method the caller reached for. + /// + [TestMethod] + public async Task TestBothRequestPathsSendTheSameVersion() + { + using RequestCapturingServer server = new RequestCapturingServer(); + using XrplClient client = new XrplClient(server.Url, new XrplClient.ClientOptions { ApiVersion = 2 }); + + await client.Connect().ConfigureAwait(false); + await client.ServerInfo(new ServerInfoRequest()).ConfigureAwait(false); + await client.Request(new Dictionary { ["command"] = UntypedCommand }).ConfigureAwait(false); + await client.Disconnect().ConfigureAwait(false); + + string typed = server.LastRequestFor("server_info"); + string untyped = server.LastRequestFor(UntypedCommand); + + Assert.IsNotNull(typed, "the server saw no typed request"); + Assert.IsNotNull(untyped, "the server saw no untyped request"); + + Assert.AreEqual(2u, ApiVersionOf(typed), $"typed request: {typed}"); + Assert.AreEqual(2u, ApiVersionOf(untyped), $"untyped request: {untyped}"); + AssertNoMemberNameOnTheWire(untyped); + } + } +} diff --git a/Tests/Xrpl.Tests/Client/TestUResponseParsing.cs b/Tests/Xrpl.Tests/Client/TestUResponseParsing.cs new file mode 100644 index 00000000..ff3c5742 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestUResponseParsing.cs @@ -0,0 +1,403 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using Xrpl.Client; +using Xrpl.Client.Exceptions; +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; + +namespace Xrpl.Tests.ClientLib +{ + /// + /// Covers how turns a response into the requested type. The + /// response arrives already parsed, so the result member is deserialized straight from + /// its : rendering it back to text and parsing it a second time used + /// to cost two extra copies of the whole response per request, both large-object-heap sized on + /// a paged crawl. These tests pin the behaviour that must survive that, and the allocation + /// budget that must not creep back up. + /// + [TestClass] + public class TestUResponseParsing + { + private static string BuildLedgerDataMessage(Guid id, int entries) + { + StringBuilder builder = new StringBuilder(entries * 128 + 256); + builder.Append("{\"id\":\"").Append(id.ToString("D")) + .Append("\",\"status\":\"success\",\"type\":\"response\",\"result\":{") + .Append("\"ledger_hash\":\"842B57C1CC0613299A686D3E9F310EC0422C84D3911E5056389AA7E5808A93C8\",") + .Append("\"ledger_index\":96000000,\"validated\":true,\"marker\":\"AABBCCDD\",\"state\":["); + + for (int i = 0; i < entries; i++) + { + if (i > 0) + { + builder.Append(','); + } + + builder.Append("{\"LedgerEntryType\":\"AccountRoot\",\"Account\":\"rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH\",") + .Append("\"Balance\":\"").Append(1000000 + i) + .Append("\",\"Flags\":0,\"OwnerCount\":").Append(i % 17) + .Append(",\"Sequence\":").Append(i + 1) + .Append(",\"index\":\"").Append(i.ToString("X64")).Append("\"}"); + } + + builder.Append("]}}"); + return builder.ToString(); + } + + /// Rewrites the 36-character id of a prebuilt message in place. + private static void WriteId(byte[] message, int offset, Guid id) + { + string text = id.ToString("D"); + for (int i = 0; i < text.Length; i++) + { + message[offset + i] = (byte)text[i]; + } + } + + private static RequestManager.XrplGRequest Pending(RequestManager manager) + { + return manager.CreateGRequest( + new LedgerDataRequest { Limit = 4 }, + System.Threading.Timeout.InfiniteTimeSpan); + } + + [TestMethod] + public void TestUntypedRequestGetsTheParsedResultNode() + { + RequestManager manager = new RequestManager(); + RequestManager.XrplGRequest pending = Pending(manager); + + manager.HandleResponse(BuildLedgerDataMessage(pending.Id, 4)); + + JsonElement result = (JsonElement)pending.Promise.GetAwaiter().GetResult(); + Assert.AreEqual(JsonValueKind.Object, result.ValueKind); + Assert.AreEqual(4, result.GetProperty("state").GetArrayLength()); + Assert.AreEqual(96000000, result.GetProperty("ledger_index").GetInt32()); + Assert.AreEqual("AABBCCDD", result.GetProperty("marker").GetString()); + + // The element must outlive the parse: it is handed out rather than copied, so it has + // to own its data and stay readable after everything else is collected. + GC.Collect(2, GCCollectionMode.Forced, blocking: true); + Assert.AreEqual(4, result.GetProperty("state").GetArrayLength()); + } + + [TestMethod] + public void TestTypedRequestDeserializesFromTheParsedResultNode() + { + RequestManager manager = new RequestManager(); + RequestManager.XrplGRequest pending = Pending(manager); + + manager.HandleResponse(BuildLedgerDataMessage(pending.Id, 3)); + + LOLedgerData result = (LOLedgerData)pending.Promise.GetAwaiter().GetResult(); + Assert.IsNotNull(result); + Assert.AreEqual(96000000u, result.LedgerIndex); + Assert.AreEqual("842B57C1CC0613299A686D3E9F310EC0422C84D3911E5056389AA7E5808A93C8", result.LedgerHash); + Assert.IsNotNull(result.State); + Assert.AreEqual(3, result.State.Count); + } + + [TestMethod] + public void TestUtf8AndStringOverloadsProduceTheSameResult() + { + RequestManager manager = new RequestManager(); + + RequestManager.XrplGRequest viaString = Pending(manager); + string message = BuildLedgerDataMessage(viaString.Id, 5); + manager.HandleResponse(message); + + RequestManager.XrplGRequest viaBytes = Pending(manager); + manager.HandleResponse(Encoding.UTF8.GetBytes(BuildLedgerDataMessage(viaBytes.Id, 5))); + + LOLedgerData fromString = (LOLedgerData)viaString.Promise.GetAwaiter().GetResult(); + LOLedgerData fromBytes = (LOLedgerData)viaBytes.Promise.GetAwaiter().GetResult(); + + Assert.AreEqual(fromString.LedgerIndex, fromBytes.LedgerIndex); + Assert.AreEqual(fromString.LedgerHash, fromBytes.LedgerHash); + Assert.AreEqual(fromString.State.Count, fromBytes.State.Count); + } + + [TestMethod] + public void TestResponseWithoutResultStillCompletes() + { + RequestManager manager = new RequestManager(); + + RequestManager.XrplGRequest untyped = Pending(manager); + manager.HandleResponse($"{{\"id\":\"{untyped.Id:D}\",\"status\":\"success\",\"type\":\"response\",\"result\":null}}"); + JsonElement empty = (JsonElement)untyped.Promise.GetAwaiter().GetResult(); + Assert.AreEqual(JsonValueKind.Object, empty.ValueKind); + Assert.IsFalse(empty.TryGetProperty("state", out _)); + + RequestManager.XrplGRequest typed = Pending(manager); + manager.HandleResponse($"{{\"id\":\"{typed.Id:D}\",\"status\":\"success\",\"type\":\"response\"}}"); + LOLedgerData defaults = (LOLedgerData)typed.Promise.GetAwaiter().GetResult(); + Assert.IsNotNull(defaults); + Assert.IsNull(defaults.State); + } + + [TestMethod] + public void TestErrorStatusRejectsWithTheParsedErrorResponse() + { + RequestManager manager = new RequestManager(); + RequestManager.XrplGRequest pending = Pending(manager); + + manager.HandleResponse( + $"{{\"id\":\"{pending.Id:D}\",\"status\":\"error\",\"type\":\"response\"," + + "\"error\":\"lgrNotFound\",\"error_message\":\"ledgerNotFound\"}"); + + RippledException rippled = null; + try + { + pending.Promise.Wait(); + } + catch (AggregateException raised) + { + rippled = raised.InnerException as RippledException; + } + + Assert.IsNotNull(rippled, "the request should have been rejected with a RippledException"); + StringAssert.Contains(rippled.Message, "lgrNotFound"); + Assert.IsNotNull(rippled.Response, "the parsed error response must be attached"); + Assert.AreEqual("lgrNotFound", rippled.Response.Error); + Assert.AreEqual("ledgerNotFound", rippled.Response.ErrorMessage); + } + + /// + /// Guards the allocation budget of the response path. Before the result node was + /// deserialized directly, one response cost about 7.4 times its own byte length: a UTF-16 + /// copy of the message, a document over it, a second UTF-16 copy of the result, and a + /// second document over that. The direct path costs about half of it from a string and + /// about 1.7 times from UTF-8 bytes. The bound below sits between the two, well clear of + /// both, so it fails only if the double round-trip comes back. + /// + [TestMethod] + public void TestResponseParsingStaysWithinItsAllocationBudget() + { + const int Entries = 4096; + const int Rounds = 12; + + RequestManager manager = new RequestManager(); + + // Built once and reused, with only the id rewritten in place, so nothing the harness + // allocates lands inside the measured window. + RequestManager.XrplGRequest warmup = Pending(manager); + byte[] message = Encoding.UTF8.GetBytes(BuildLedgerDataMessage(warmup.Id, Entries)); + const int IdOffset = 7; // past {"id":" + manager.HandleResponse(message); + _ = warmup.Promise.GetAwaiter().GetResult(); + + GC.Collect(2, GCCollectionMode.Forced, blocking: true); + long before = GC.GetAllocatedBytesForCurrentThread(); + + for (int i = 0; i < Rounds; i++) + { + RequestManager.XrplGRequest pending = Pending(manager); + WriteId(message, IdOffset, pending.Id); + manager.HandleResponse(message); + JsonElement result = (JsonElement)pending.Promise.GetAwaiter().GetResult(); + Assert.AreEqual(Entries, result.GetProperty("state").GetArrayLength()); + } + + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + double perResponse = allocated / (double)Rounds; + double ratio = perResponse / message.Length; + + Console.WriteLine($"response {message.Length:N0} bytes, {perResponse / 1024 / 1024:F2} MB allocated per response ({ratio:F2}x)"); + + Assert.IsTrue( + ratio < 4.0, + $"response parsing allocated {ratio:F2}x the response size, budget is 4x " + + "(the pre-fix double round-trip cost about 7x here)"); + } + + /// + /// The same budget one level up, over a real socket, because the budget above cannot see + /// which overload chooses. Binding the string callback again + /// would put a UTF-16 copy of every frame back on the path and nothing else in the suite + /// would notice. + /// + /// + /// Allocations here happen on the receive loop's thread, so this has to read the + /// process-wide counter, which is why the test is kept out of the parallel pass. + /// + [TestMethod] + [DoNotParallelize] + public async Task TestSocketPathKeepsResponsesInTheirWireForm() + { + const int Pages = 20; + const int PayloadBytes = 1024 * 1024; + + using PagedResponseServer server = new PagedResponseServer(PayloadBytes, fragments: 8); + using XrplClient client = new XrplClient(server.Url); + + await client.Connect().ConfigureAwait(false); + await CrawlPageAsync(client).ConfigureAwait(false); + + GC.Collect(2, GCCollectionMode.Forced, blocking: true); + GC.WaitForPendingFinalizers(); + GC.Collect(2, GCCollectionMode.Forced, blocking: true); + + long before = GC.GetTotalAllocatedBytes(precise: true); + + for (int i = 0; i < Pages; i++) + { + await CrawlPageAsync(client).ConfigureAwait(false); + } + + long allocated = GC.GetTotalAllocatedBytes(precise: true) - before; + await client.Disconnect().ConfigureAwait(false); + + double ratio = allocated / (double)Pages / PayloadBytes; + Console.WriteLine($"socket path: {allocated / (double)Pages / 1024 / 1024:F2} MB allocated per page ({ratio:F2}x)"); + + Assert.IsTrue( + ratio < 3.0, + $"the socket path allocated {ratio:F2}x the payload per page, budget is 3x " + + "(2.18x as bound, 4.84x with the string callback bound instead)"); + } + + /// + /// A response carrying warning/warnings still reaches both callbacks. The + /// text they are handed is now built only when one of them is subscribed, so this is the + /// side of that condition that must not have been broken. + /// + [TestMethod] + public async Task TestWarningsStillReachTheirCallbacks() + { + using PagedResponseServer server = new PagedResponseServer(64 * 1024, fragments: 1, withWarnings: true); + using XrplClient client = new XrplClient(server.Url); + + TaskCompletionSource<(string Warning, string Message)> warning = + new TaskCompletionSource<(string, string)>(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource<(int Count, string Message)> serverWarnings = + new TaskCompletionSource<(int, string)>(TaskCreationOptions.RunContinuationsAsynchronously); + + await client.Connect().ConfigureAwait(false); + + client.connection.OnWarning += (text, message) => + { + warning.TrySetResult((text, message)); + return Task.CompletedTask; + }; + + client.connection.OnServerWarning += (warnings, message) => + { + serverWarnings.TrySetResult((warnings.Count, message)); + return Task.CompletedTask; + }; + + await CrawlPageAsync(client).ConfigureAwait(false); + + Task both = Task.WhenAll(warning.Task, serverWarnings.Task); + Task finished = await Task.WhenAny(both, Task.Delay(TimeSpan.FromSeconds(10))).ConfigureAwait(false); + await client.Disconnect().ConfigureAwait(false); + + Assert.AreSame(both, finished, "a warned response did not reach OnWarning/OnServerWarning"); + + (string Warning, string Message) warned = await warning.Task.ConfigureAwait(false); + (int Count, string Message) served = await serverWarnings.Task.ConfigureAwait(false); + + Assert.AreEqual("load", warned.Warning); + Assert.AreEqual(1, served.Count); + + // The message the callbacks are handed is the point of the condition guarding it: they + // must get the response text, not null and not the out-of-memory placeholder. + foreach (string text in new[] { warned.Message, served.Message }) + { + Assert.IsNotNull(text, "the warning callbacks were handed no message"); + StringAssert.Contains(text, "\"warning\":\"load\""); + StringAssert.Contains(text, "\"state\":["); + } + } + + /// + /// And the other side of it: warnings on every page with nothing subscribed must not put + /// the UTF-16 copy of each response back on the path. + /// + /// Reads the process-wide counter, so it stays out of the parallel pass. + [TestMethod] + [DoNotParallelize] + public async Task TestUnsubscribedWarningsCostNothing() + { + const int Pages = 20; + const int PayloadBytes = 1024 * 1024; + + using PagedResponseServer server = new PagedResponseServer(PayloadBytes, fragments: 8, withWarnings: true); + using XrplClient client = new XrplClient(server.Url); + + await client.Connect().ConfigureAwait(false); + await CrawlPageAsync(client).ConfigureAwait(false); + + GC.Collect(2, GCCollectionMode.Forced, blocking: true); + GC.WaitForPendingFinalizers(); + GC.Collect(2, GCCollectionMode.Forced, blocking: true); + + long before = GC.GetTotalAllocatedBytes(precise: true); + + for (int i = 0; i < Pages; i++) + { + await CrawlPageAsync(client).ConfigureAwait(false); + } + + long allocated = GC.GetTotalAllocatedBytes(precise: true) - before; + await client.Disconnect().ConfigureAwait(false); + + double ratio = allocated / (double)Pages / PayloadBytes; + Console.WriteLine($"warned pages, no subscribers: {allocated / (double)Pages / 1024 / 1024:F2} MB per page ({ratio:F2}x)"); + + Assert.IsTrue( + ratio < 3.0, + $"warned responses allocated {ratio:F2}x the payload per page with nothing subscribed, " + + "budget is 3x (2.08x as bound, 4.28x when the text is built regardless of subscribers)"); + } + + /// + /// The string entry point is public and used by tests and consumers that feed messages in + /// by hand. A null there travelled down to the stream processor and came back out through + /// OnError as a badMessage; carrying the frame as bytes must not turn that + /// into a throw out of the method itself, and must not turn it into silence either. + /// + [TestMethod] + public async Task TestNullMessageIsStillReportedThroughOnError() + { + Connection connection = new Connection("ws://127.0.0.1:1/"); + TaskCompletionSource reported = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + connection.OnError += (error, errorMessage, message, data) => + { + reported.TrySetResult(errorMessage); + return Task.CompletedTask; + }; + + // Must not throw: the entry point is public and a null used to be routed, not raised. + await connection.OnMessage(null).ConfigureAwait(false); + + // The routing itself is fire-and-forget, so the report arrives after the call returns. + Task completed = await Task.WhenAny(reported.Task, Task.Delay(TimeSpan.FromSeconds(5))) + .ConfigureAwait(false); + + Assert.AreSame(reported.Task, completed, "a null message was dropped instead of being reported"); + Assert.AreEqual("badMessage", await reported.Task.ConfigureAwait(false)); + } + + private static async Task CrawlPageAsync(XrplClient client) + { + JsonElement page = await client + .GRequest(new LedgerDataRequest { Binary = true, Limit = 2048 }) + .ConfigureAwait(false); + + if (page.GetProperty("state").GetArrayLength() == 0) + { + throw new InvalidOperationException("ledger_data page carried no objects"); + } + } + } +} diff --git a/Tests/Xrpl.Tests/Client/WebSocketTestServerBase.cs b/Tests/Xrpl.Tests/Client/WebSocketTestServerBase.cs index 9ac8d07a..baea79c7 100644 --- a/Tests/Xrpl.Tests/Client/WebSocketTestServerBase.cs +++ b/Tests/Xrpl.Tests/Client/WebSocketTestServerBase.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Buffers.Binary; using System.Net; using System.Net.Sockets; using System.Text; @@ -201,6 +202,77 @@ private async Task ReadUntilHeadersEndAsync(NetworkStream stream) return request.ToString(); } + /// + /// Reads one client frame. Returns the decoded text of the first text frame seen, or null + /// once the peer closes. Control frames other than Close are skipped. + /// + protected async Task ReadTextFrameAsync(NetworkStream stream) + { + while (true) + { + byte[] head = new byte[2]; + if (!await ReadExactAsync(stream, head, 2).ConfigureAwait(false)) + { + return null; + } + + int opcode = head[0] & 0x0F; + bool masked = (head[1] & 0x80) != 0; + long length = head[1] & 0x7F; + + if (length == 126) + { + byte[] extended = new byte[2]; + if (!await ReadExactAsync(stream, extended, 2).ConfigureAwait(false)) + { + return null; + } + + length = BinaryPrimitives.ReadUInt16BigEndian(extended); + } + else if (length == 127) + { + byte[] extended = new byte[8]; + if (!await ReadExactAsync(stream, extended, 8).ConfigureAwait(false)) + { + return null; + } + + length = (long)BinaryPrimitives.ReadUInt64BigEndian(extended); + } + + byte[] mask = new byte[4]; + if (masked && !await ReadExactAsync(stream, mask, 4).ConfigureAwait(false)) + { + return null; + } + + byte[] payload = new byte[length]; + if (length > 0 && !await ReadExactAsync(stream, payload, (int)length).ConfigureAwait(false)) + { + return null; + } + + if (masked) + { + for (int i = 0; i < payload.Length; i++) + { + payload[i] ^= mask[i % 4]; + } + } + + if (opcode == 0x8) + { + return null; + } + + if (opcode == 0x1 || opcode == 0x2) + { + return Encoding.UTF8.GetString(payload); + } + } + } + public void Dispose() { try diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIEscrow.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIEscrow.cs index 5f6ea53a..7983addc 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIEscrow.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIEscrow.cs @@ -1,4 +1,4 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Threading.Tasks; using Xrpl.Client; @@ -20,6 +20,26 @@ public class TestIEscrow public static IXrplClient client; private static TestNodeType nodeType = TestNodeType.Standalone; + + /// + /// How far ahead of the last validated close time an escrow's FinishAfter is placed. + /// + /// + /// rippled rejects an EscrowCreate whose FinishAfter already sits behind the + /// close time of the parent of the ledger it lands in — tecNO_PERMISSION, verified + /// against the stand. The close time this is measured from is read one round trip earlier, and + /// the standalone stand closes a ledger every 4 seconds (see the ledger-acceptor loop in + /// .ci-config/docker-compose.ci.yml), so any margin at or below one ledger interval makes the + /// create a coin flip. This is three intervals, which covers the read, the autofill and the + /// submit with room left over. + /// + private static readonly TimeSpan FinishAfterMargin = TimeSpan.FromSeconds(12); + + /// + /// Same idea for CancelAfter, which rippled requires to be later than + /// and which the cancel tests wait out in full. + /// + private static readonly TimeSpan CancelAfterMargin = TimeSpan.FromSeconds(24); //static XrplWallet walletIssuer = XrplWallet.Generate(); //static XrplWallet walletHolder1 = XrplWallet.Generate(); @@ -57,7 +77,7 @@ public async Task TestXrpEscrowCreate_AndFinish() Account = walletHolder1.ClassicAddress, Amount = new Currency { ValueAsXrp = 1 }, Destination = walletHolder2.ClassicAddress, - FinishAfter = closeTime + TimeSpan.FromSeconds(2), + FinishAfter = closeTime + FinishAfterMargin, }; escrowCreateTx = await client.Autofill(escrowCreateTx); uint escrowSequence = (uint)escrowCreateTx.Sequence; @@ -68,7 +88,7 @@ public async Task TestXrpEscrowCreate_AndFinish() AccountObjects objResp = await client.AccountObjects(objReq); Assert.IsTrue(objResp.AccountObjectList.Count >= 1, "At least one escrow should exist after creation"); - await WaitForLedgerCloseTime(client, closeTime.Value + TimeSpan.FromSeconds(2)); + await WaitForLedgerCloseTime(client, closeTime.Value + FinishAfterMargin); EscrowFinish finishTx = new EscrowFinish { @@ -96,14 +116,14 @@ public async Task TestXrpEscrowCreate_AndCancel() LedgerEntity ledgerEntity = (LedgerEntity)ledgerResponse.LedgerEntity; var closeTime = ledgerEntity.CloseTime; - var cancelAfterTime = closeTime + TimeSpan.FromSeconds(10); + var cancelAfterTime = closeTime + CancelAfterMargin; var escrowCreateTx = new EscrowCreate { Account = walletHolder1.ClassicAddress, Amount = new Currency { ValueAsXrp = 1 }, Destination = walletHolder2.ClassicAddress, CancelAfter = cancelAfterTime, - FinishAfter = closeTime + TimeSpan.FromSeconds(2), + FinishAfter = closeTime + FinishAfterMargin, }; escrowCreateTx = await client.Autofill(escrowCreateTx); uint escrowSequence = (uint)escrowCreateTx.Sequence; @@ -188,7 +208,7 @@ public async Task TestIOUEscrowCreate_AndFinish() Account = walletHolder1.ClassicAddress, Amount = new Currency { CurrencyCode = "USD", Issuer = walletIssuer.ClassicAddress, Value = "100" }, Destination = walletHolder2.ClassicAddress, - FinishAfter = closeTime + TimeSpan.FromSeconds(2), + FinishAfter = closeTime + FinishAfterMargin, }; escrowCreateTx = await client.Autofill(escrowCreateTx); uint escrowSequence = (uint)escrowCreateTx.Sequence; @@ -199,7 +219,7 @@ public async Task TestIOUEscrowCreate_AndFinish() AccountObjects objResp = await client.AccountObjects(objReq); Assert.IsTrue(objResp.AccountObjectList.Count >= 1, "At least one escrow should exist after creation"); - await WaitForLedgerCloseTime(client, closeTime.Value + TimeSpan.FromSeconds(2)); + await WaitForLedgerCloseTime(client, closeTime.Value + FinishAfterMargin); EscrowFinish finishTx = new EscrowFinish { @@ -264,14 +284,14 @@ public async Task TestIOUEscrowCreate_AndCancel() LedgerEntity ledgerEntity = (LedgerEntity)ledgerResponse.LedgerEntity; var closeTime = ledgerEntity.CloseTime; - var cancelAfterTime = closeTime + TimeSpan.FromSeconds(10); + var cancelAfterTime = closeTime + CancelAfterMargin; var escrowCreateTx = new EscrowCreate { Account = walletHolder1.ClassicAddress, Amount = new Currency { CurrencyCode = "USD", Issuer = walletIssuer.ClassicAddress, Value = "100" }, Destination = walletHolder2.ClassicAddress, CancelAfter = cancelAfterTime, - FinishAfter = closeTime + TimeSpan.FromSeconds(2), + FinishAfter = closeTime + FinishAfterMargin, }; escrowCreateTx = await client.Autofill(escrowCreateTx); uint escrowSequence = (uint)escrowCreateTx.Sequence; @@ -368,7 +388,7 @@ public async Task TestMPTEscrowCreate_AndFinish() Account = walletHolder1.ClassicAddress, Amount = new Currency { MPTokenIssuanceID = issuanceId, Value = "1000" }, Destination = walletHolder2.ClassicAddress, - FinishAfter = closeTime + TimeSpan.FromSeconds(2), + FinishAfter = closeTime + FinishAfterMargin, }; escrowCreateTx = await client.Autofill(escrowCreateTx); @@ -380,7 +400,7 @@ public async Task TestMPTEscrowCreate_AndFinish() AccountObjects objResp = await client.AccountObjects(objReq); Assert.IsTrue(objResp.AccountObjectList.Count >= 1, "At least one escrow should exist after creation"); - await WaitForLedgerCloseTime(client, closeTime.Value + TimeSpan.FromSeconds(2)); + await WaitForLedgerCloseTime(client, closeTime.Value + FinishAfterMargin); EscrowFinish finishTx = new EscrowFinish @@ -449,14 +469,14 @@ public async Task TestMPTEscrowCreate_AndCancel() LedgerEntity ledgerEntity = (LedgerEntity)ledgerResponse.LedgerEntity; var closeTime = ledgerEntity.CloseTime; - var cancelAfterTime = closeTime + TimeSpan.FromSeconds(10); + var cancelAfterTime = closeTime + CancelAfterMargin; var escrowCreateTx = new EscrowCreate { Account = walletHolder1.ClassicAddress, Amount = new Currency { MPTokenIssuanceID = issuanceId, Value = "1000" }, Destination = walletHolder2.ClassicAddress, CancelAfter = cancelAfterTime, - FinishAfter = closeTime + TimeSpan.FromSeconds(2), + FinishAfter = closeTime + FinishAfterMargin, }; escrowCreateTx = await client.Autofill(escrowCreateTx); uint escrowSequence = (uint)escrowCreateTx.Sequence; diff --git a/Tests/Xrpl.Tests/Models/TestUTransactionStreamEnvelope.cs b/Tests/Xrpl.Tests/Models/TestUTransactionStreamEnvelope.cs new file mode 100644 index 00000000..133c4900 --- /dev/null +++ b/Tests/Xrpl.Tests/Models/TestUTransactionStreamEnvelope.cs @@ -0,0 +1,166 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Text.Json; + +using Xrpl.Client.Json; +using Xrpl.Models; +using Xrpl.Models.Subscriptions; +using Xrpl.Models.Transactions; + +// Regression tests for the API v1 / v2 payload shapes of the transaction streams, the same split +// TestUAccountTransactionsEnvelope pins for account_tx. rippled wraps the transaction in tx_json +// under API v2 and in transaction under API v1, and moves the hash with it: v2 reports it at the +// top level, v1 only inside the envelope. Payloads below are trimmed captures of real mainnet +// stream messages from s2.ripple.com. +namespace XrplTests.Xrpl.Models +{ + [TestClass] + public class TestUTransactionStreamEnvelope + { + private const string V1Hash = "96E02B092A9EDE4F7DA45E5DF8CE353AEEDAB1EFB46646146EAC582FFED19039"; + private const string V2Hash = "BC4F1500B56FE32D51AF23893CAA0FC972E2400E570151CA61FAC563A7C452E7"; + private const string Account = "rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd"; + + private const string StreamApiV1 = """ + { + "type": "transaction", + "status": "closed", + "validated": true, + "close_time_iso": "2026-08-16T18:39:42Z", + "ledger_index": 106338962, + "ledger_hash": "CD27564825B9F6177964FB3231D0CF0EA29C6E1E5F0D0D5F5F2D6E5C4B3A2918", + "engine_result": "tesSUCCESS", + "engine_result_code": 0, + "engine_result_message": "The transaction was applied. Only final in a validated ledger.", + "transaction": { + "TransactionType": "OfferCreate", + "Account": "rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd", + "Fee": "12", + "Sequence": 84512339, + "TakerGets": "9000000", + "TakerPays": { "currency": "USD", "issuer": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B", "value": "5.25" }, + "date": 808684782, + "hash": "96E02B092A9EDE4F7DA45E5DF8CE353AEEDAB1EFB46646146EAC582FFED19039" + }, + "meta": { + "AffectedNodes": [], + "TransactionIndex": 7, + "TransactionResult": "tesSUCCESS" + } + } + """; + + private const string StreamApiV2 = """ + { + "type": "transaction", + "status": "closed", + "validated": true, + "close_time_iso": "2026-08-16T18:39:50Z", + "ledger_index": 106338963, + "ledger_hash": "6F6656D47E7E667C75DD2B961A0C2E4D3B5A6978C1D2E3F4A5B6C7D8E9F00112", + "hash": "BC4F1500B56FE32D51AF23893CAA0FC972E2400E570151CA61FAC563A7C452E7", + "engine_result": "tesSUCCESS", + "engine_result_code": 0, + "engine_result_message": "The transaction was applied. Only final in a validated ledger.", + "tx_json": { + "TransactionType": "OfferCreate", + "Account": "rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd", + "Fee": "12", + "Sequence": 84512340, + "TakerGets": "9000000", + "TakerPays": { "currency": "USD", "issuer": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B", "value": "5.25" }, + "date": 808684790 + }, + "meta": { + "AffectedNodes": [], + "TransactionIndex": 8, + "TransactionResult": "tesSUCCESS" + } + } + """; + + /// A stream message carrying neither envelope, which the type must survive. + private const string StreamWithoutEnvelope = """ + { + "type": "transaction", + "status": "closed", + "validated": true, + "ledger_index": 106338964, + "engine_result": "tesSUCCESS", + "engine_result_code": 0 + } + """; + + private static TransactionStream Parse(string message) + { + return JsonSerializer.Deserialize(message, XrplJsonOptions.Default); + } + + [TestMethod] + public void TestTransactionStreamApiV1Envelope() + { + TransactionStream stream = Parse(StreamApiV1); + + Assert.IsNotNull(stream); + Assert.IsNotNull(stream.Transaction, "API v1 wraps the transaction in transaction instead of tx_json"); + Assert.AreEqual(TransactionType.OfferCreate, stream.Transaction.TransactionType); + Assert.AreEqual(Account, stream.Transaction.Account); + Assert.AreEqual(V1Hash, stream.Hash, "API v1 reports the hash inside the transaction envelope"); + Assert.AreEqual(106338962ul, stream.LedgerIndex); + Assert.AreEqual("tesSUCCESS", stream.EngineResult); + } + + [TestMethod] + public void TestTransactionStreamApiV2Envelope() + { + TransactionStream stream = Parse(StreamApiV2); + + Assert.IsNotNull(stream); + Assert.IsNotNull(stream.Transaction, "API v2 wraps the transaction in tx_json"); + Assert.AreEqual(TransactionType.OfferCreate, stream.Transaction.TransactionType); + Assert.AreEqual(Account, stream.Transaction.Account); + Assert.AreEqual(V2Hash, stream.Hash, "API v2 reports the hash at the top level"); + Assert.AreEqual(106338963ul, stream.LedgerIndex); + } + + [TestMethod] + public void TestTransactionStreamWithoutEnvelopeDoesNotThrow() + { + TransactionStream stream = Parse(StreamWithoutEnvelope); + + Assert.IsNotNull(stream); + Assert.IsNull(stream.Transaction, "a message carrying neither envelope must read as no transaction"); + Assert.IsNull(stream.Hash); + } + + /// + /// The transaction is deserialized once, with the message that carries it. Reading it back + /// is a field read and must cost nothing: the property used to re-parse the whole + /// transaction on every single access, on the busiest path in the client. + /// + [TestMethod] + public void TestRepeatedTransactionAccessCostsNothing() + { + const int Reads = 50; + + TransactionStream stream = Parse(StreamApiV1); + TransactionResponse first = stream.Transaction; + Assert.IsNotNull(first); + + long before = GC.GetAllocatedBytesForCurrentThread(); + + for (int i = 0; i < Reads; i++) + { + Assert.AreSame(first, stream.Transaction, "every read must hand back the same instance"); + } + + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.IsTrue( + allocated < 1024, + $"{Reads} reads of Transaction allocated {allocated} bytes; re-parsing per access costs " + + "kilobytes per read and is what this pins against"); + } + } +} diff --git a/Xrpl/Client/IXrplClient.cs b/Xrpl/Client/IXrplClient.cs index 90633b2f..76230b73 100644 --- a/Xrpl/Client/IXrplClient.cs +++ b/Xrpl/Client/IXrplClient.cs @@ -469,6 +469,13 @@ public class ClientOptions : ConnectionOptions public string maxFeeXRP { get; set; } public uint? networkID { get; set; } + /// + /// rippled's name for the API version field. Typed requests get it from + /// 's [JsonPropertyName]; a dictionary request + /// has to spell it out, since its keys reach the wire exactly as written. + /// + private const string ApiVersionField = "api_version"; + /// /// The API version to use when making requests. /// @@ -900,10 +907,17 @@ public async Task> Request(Dictionary { //string account = request["Account"] ? EnsureClassicAddress((string)request["account"]) : null; //request["Account"] = account; - if(!request.TryGetValue(nameof(ApiVersion), out var value)){ + + // The key has to be the wire name. A dictionary is serialized verbatim, and rippled + // knows only `api_version` - it ignores anything else and answers on its default, + // API v1. Stamping `nameof(ApiVersion)` here meant this path never delivered the + // version at all, so the same client spoke v2 through its typed methods and v1 + // through this one. + if (!request.ContainsKey(ApiVersionField)) { - request[nameof(ApiVersion)] = ApiVersion; - }} + request[ApiVersionField] = ApiVersion; + } + var response = await this.connection.Request(request, cancellationToken: cancellationToken); // mutates `response` to add warnings diff --git a/Xrpl/Client/RequestManager.cs b/Xrpl/Client/RequestManager.cs index 2a18e1be..74c1b60d 100644 --- a/Xrpl/Client/RequestManager.cs +++ b/Xrpl/Client/RequestManager.cs @@ -55,6 +55,20 @@ public class XrplGRequest private readonly ConcurrentDictionary promisesAwaitingResponse = new ConcurrentDictionary(); private readonly JsonSerializerOptions serializerOptions = XrplJsonOptions.Default; + /// + /// Stands in for a missing result, matching what deserializing the literal + /// "{}" used to produce. + /// + private static readonly JsonElement EmptyResult = ParseEmptyObject(); + + private static JsonElement ParseEmptyObject() + { + using (JsonDocument document = JsonDocument.Parse("{}")) + { + return document.RootElement.Clone(); + } + } + public RequestManager() { } @@ -74,7 +88,7 @@ public void Resolve(Guid id, BaseResponse response) try { - object deserialized = JsonSerializer.Deserialize(response.Result?.ToString() ?? "{}", taskInfo.Type, serializerOptions); + object deserialized = DeserializeResult(response.Result, taskInfo.Type); CompleteWithResult(taskInfo, deserialized); this.DeletePromise(id, taskInfo); } @@ -86,6 +100,47 @@ public void Resolve(Guid id, BaseResponse response) } } + /// + /// Converts the result member of a response into the type the request was created + /// with. + /// + /// + /// The member arrives already parsed - is typed + /// , which System.Text.Json fills with a self-contained + /// . Rendering that element back to a string and parsing the + /// string a second time cost two more copies of the whole response per request: a UTF-16 + /// string at twice the byte length, and a second document on top of it. On a paged walk + /// of the ledger both copies are large-object-heap sized and both are pure waste, so the + /// element is deserialized directly instead, and handed straight through when the request + /// asked for the untyped node in the first place. + /// + private object DeserializeResult(object result, Type type) + { + JsonElement element; + + if (result is null) + { + element = EmptyResult; + } + else if (result is JsonElement parsed) + { + element = parsed; + } + else + { + // A response assembled by hand rather than parsed off the wire: there is no node + // to reuse, so this is still the only way in. + return JsonSerializer.Deserialize(result.ToString(), type, serializerOptions); + } + + if (type == typeof(JsonElement) || type == typeof(object)) + { + return element; + } + + return element.Deserialize(type, serializerOptions); + } + /// /// Rejects a pending request with the specified exception. /// Safe to call even if the promise no longer exists (e.g., already resolved). @@ -444,8 +499,25 @@ public XrplRequest CreateRequest( /// public (BaseResponse Response, bool Handled) HandleResponse(string message) { - var response = JsonSerializer.Deserialize(message, serializerOptions); + return HandleResponse(JsonSerializer.Deserialize(message, serializerOptions)); + } + + /// + /// Same as for a message still in its wire form. + /// + /// + /// Preferred on the socket path: transcoding the frame to a UTF-16 string first costs a + /// copy at twice the byte length of the message, which for a large response is a + /// large-object-heap allocation spent only to hand System.Text.Json something it converts + /// straight back to UTF-8. + /// + public (BaseResponse Response, bool Handled) HandleResponse(ReadOnlySpan utf8Message) + { + return HandleResponse(JsonSerializer.Deserialize(utf8Message, serializerOptions)); + } + private (BaseResponse Response, bool Handled) HandleResponse(ErrorResponse response) + { if (response.Id == null) { return (response, false); @@ -481,21 +553,13 @@ public XrplRequest CreateRequest( if (response.Status == "error" ) { - ErrorResponse errorResponse = null; - try - { - errorResponse = JsonSerializer.Deserialize(message, serializerOptions); - - } - catch (Exception e) - { - - } + // The message was already deserialized into an ErrorResponse above, so the error + // details are in hand - parsing it a second time only produced an equal copy. string detail = response.ErrorMessage ?? response.ErrorException; var errMessage = response.Error is null ? detail : $"{response.Error} - {detail}"; - var error = new RippledException(errMessage, errorResponse); + var error = new RippledException(errMessage, response); this.Reject(id, error); return (response, true); } diff --git a/Xrpl/Client/connection.cs b/Xrpl/Client/connection.cs index 84afbe80..e330e7fb 100644 --- a/Xrpl/Client/connection.cs +++ b/Xrpl/Client/connection.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.IO; using System.Net.WebSockets; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Channels; @@ -1060,7 +1061,10 @@ await errorHandler.Invoke( } }); - ws.OnMessageReceived(async (m, ws) => + // Bound to the binary callback rather than the string one: the frame is already UTF-8 + // and that is what the JSON reader wants, so the UTF-16 copy of every message - twice + // the byte length, on the large object heap for a big response - is never made. + ws.OnBinaryMessage(async (m, ws) => { try { @@ -1070,7 +1074,7 @@ await errorHandler.Invoke( } catch (Exception ex) { - Debug.WriteLine($"{DateTime.Now}OnMessageReceived callback error: {ex.Message}"); + Debug.WriteLine($"{DateTime.Now}OnBinaryMessage callback error: {ex.Message}"); } }); ws.OnDisconnect(async (closeStatus, closeDescription, closingSocket) => @@ -2863,6 +2867,36 @@ private bool IsLikelyResponse(string message) return false; } + /// + /// over the raw frame, so the discriminator scan does + /// not force a UTF-16 copy of the message. Byte-wise scanning is equivalent here: the tokens + /// looked for are ASCII, and UTF-8 never encodes them inside a multi-byte sequence. + /// + private bool IsLikelyResponse(ReadOnlySpan utf8Message) + { + if (utf8Message.Length < 10) + return false; + + int firstBrace = utf8Message.IndexOf((byte)'{'); + if (firstBrace < 0 || firstBrace + 10 >= utf8Message.Length) + return false; + + ReadOnlySpan rest = utf8Message.Slice(firstBrace + 1); + int idIndex = rest.IndexOf("\"id\""u8); + if (idIndex < 0) + return false; + + int checkEnd = Math.Min(rest.Length, idIndex + 10); + for (int i = idIndex + 4; i < checkEnd; i++) + { + byte c = rest[i]; + if (c == (byte)':') return true; // This is a response + if (c != (byte)' ' && c != (byte)'\t' && c != (byte)'\n' && c != (byte)'\r') break; + } + + return false; + } + /// /// Starts the background message processor for stream messages. /// Creates a new session-bound channel and processor task. @@ -3127,13 +3161,55 @@ private async Task ProcessStreamMessageAsync(string message) /// concurrently from the ThreadPool. Handler implementations MUST be thread-safe /// or marshal to their own synchronization context (e.g., UI thread). /// - private async Task IOnMessageFastPath(string message) + private Task IOnMessageFastPath(string message) + { + return IOnMessageFastPath(message, null); + } + + /// + /// Overload for a message still in its wire form, used by the socket callback. See + /// for why the bytes are kept as they are. + /// + private Task IOnMessageFastPath(byte[] utf8Message) + { + return IOnMessageFastPath(null, utf8Message); + } + + /// + /// Sent to in place of a message that could not be turned into text. + /// A literal, so reporting the failure needs no allocation of its own. + /// + private const string UnavailableMessageText = ""; + + /// + /// Exactly one of and carries the + /// message; the other is null. + /// + /// + /// A response is parsed straight out of when it is the one + /// present, so the UTF-16 copy of the message - twice its byte length - is never made for the + /// common case. Everything that genuinely needs text (stream messages, the warning and error + /// callbacks) asks for it through Text(), which materializes it once and only then. + /// + private async Task IOnMessageFastPath(string message, byte[] utf8Message) { lastActivityTime = DateTime.UtcNow; + // Null in, null out: the string entry point is public, and a null message used to travel + // down to the stream processor and be reported through OnError rather than throw here. + string Text() + { + if (message is null && utf8Message is not null) + { + message = Encoding.UTF8.GetString(utf8Message); + } + + return message; + } + // Scan message for "id" property to detect response messages - var isResponse = IsLikelyResponse(message); - + var isResponse = utf8Message is null ? IsLikelyResponse(message) : IsLikelyResponse(utf8Message); + if (isResponse) { // This is a response (including ping/pong) - process immediately with full parsing @@ -3141,20 +3217,42 @@ private async Task IOnMessageFastPath(string message) BaseResponse data; bool handled; try - { + { // FIRST: Handle response immediately to unblock any waiting requests (like ping) // This is the most time-critical operation - (data, handled) = requestManager.HandleResponse(message); + (data, handled) = utf8Message is null + ? requestManager.HandleResponse(message) + : requestManager.HandleResponse(utf8Message); } catch (Exception error) { var errInfo = XrplErrorClassifier.Classify(error); + if (OnError is null) + { + return; + } + + // The report has to survive whatever produced it. A response that fails to parse + // is most often a heap that has just run out, and a UTF-16 copy of the whole + // message is the largest allocation left on this path - if it cannot be had, the + // classification still goes out rather than the notification being lost to a + // second failure inside the handler. + string capturedText; + try + { + capturedText = Text(); + } + catch (OutOfMemoryException) + { + capturedText = UnavailableMessageText; + } + // Fire-and-forget for error callback - don't block _ = Task.Run(async () => { if (OnError is not null) { - await OnError.Invoke(error: "error", errorMessage: "badMessage", errInfo.UserMessage, message); + await OnError.Invoke(error: "error", errorMessage: "badMessage", errInfo.UserMessage, capturedText); } }); return; @@ -3164,16 +3262,23 @@ private async Task IOnMessageFastPath(string message) { // Message has "id" but no matching pending request — this is an async // follow-up (e.g. path_find updates). Route to stream processing. - EnqueueStreamMessage(message); + EnqueueStreamMessage(Text()); return; } // THEN: Handle warnings and errors in background (fire-and-forget) - // These are informational and should not delay response processing - if (data.Warning != null || data.Warnings is { Count: > 0 }) + // These are informational and should not delay response processing. + // Materialize the text only when something is actually listening: rippled attaches a + // warning to every response under load and on a reporting-mode server, so building a + // UTF-16 copy for a callback nobody registered would put back, page after page, + // exactly the allocation this path exists to avoid. + bool warningNeedsText = (data.Warning != null && OnWarning is not null) + || (data.Warnings is { Count: > 0 } && OnServerWarning is not null); + + if (warningNeedsText) { var capturedData = data; - var capturedMessage = message; + var capturedMessage = Text(); _ = Task.Run(async () => { if (capturedData.Warning != null && OnWarning is not null) @@ -3192,7 +3297,7 @@ private async Task IOnMessageFastPath(string message) { // This is a stream message (no "id") - process asynchronously // to avoid blocking the receive loop and causing ping timeouts - EnqueueStreamMessage(message); + EnqueueStreamMessage(Text()); } } diff --git a/Xrpl/Models/Subscriptions/TransactionStream.cs b/Xrpl/Models/Subscriptions/TransactionStream.cs index 9434aeed..2f66a43e 100644 --- a/Xrpl/Models/Subscriptions/TransactionStream.cs +++ b/Xrpl/Models/Subscriptions/TransactionStream.cs @@ -1,8 +1,6 @@ using System; -using System.Text.Json; using System.Text.Json.Serialization; -using Xrpl.Client.Json; using Xrpl.Models.Methods; using Xrpl.Models.Transactions; @@ -20,6 +18,9 @@ namespace Xrpl.Models.Subscriptions /// public class TransactionStream : BaseStream, IAccountTransaction { + private TransactionResponse _transaction; + private string _hash; + /// /// The ledger close time represented in ISO 8601 time format. /// @@ -48,8 +49,16 @@ public class TransactionStream : BaseStream, IAccountTransaction /// /// The unique hash identifier of the transaction. /// + /// + /// API v1 reports the hash inside the transaction envelope instead of at the top level, + /// so it falls back to the deserialized transaction. + /// [JsonPropertyName("hash")] - public string Hash { get; set; } + public string Hash + { + get => _hash ?? _transaction?.Hash; + set => _hash = value; + } /// /// (Validated transactions only) The identifying hash of the ledger version that includes this transaction @@ -72,19 +81,33 @@ public class TransactionStream : BaseStream, IAccountTransaction [JsonPropertyName("meta")] public Meta Meta { get; set; } /// - /// The definition of the transaction in JSON format + /// The definition of the transaction in JSON format. /// - //[JsonPropertyName("transaction")] + /// + /// rippled wraps the transaction in tx_json under API v2 and in transaction + /// under API v1; both envelopes populate this property. It is deserialized once, with the + /// message that carries it - reading it back costs nothing. + /// [JsonPropertyName("tx_json")] - public object TransactionJson { get; set; } + public TransactionResponse Transaction + { + get => _transaction; + set => _transaction = value ?? _transaction; + } + /// - /// The definition of the proposed transaction in JSON format
+ /// API v1 envelope for . ///
+ /// + /// Set-only alias: it never appears in serialized output. [JsonInclude] is required because + /// System.Text.Json ignores non-public members without it. + /// + [JsonInclude] [JsonPropertyName("transaction")] - public object Proposed { get; set; } - - [JsonIgnore] - public TransactionResponse Transaction => JsonSerializer.Deserialize((TransactionJson ?? Proposed).ToString(), XrplJsonOptions.Default); + private TransactionResponse TransactionV1 + { + set => _transaction = value ?? _transaction; + } /// /// If true, this transaction is included in a validated ledger and its outcome is final.
diff --git a/Xrpl/Xrpl.csproj b/Xrpl/Xrpl.csproj index 5cc56773..1c78a156 100644 --- a/Xrpl/Xrpl.csproj +++ b/Xrpl/Xrpl.csproj @@ -14,7 +14,7 @@ Apache-2.0 https://github.com/StaticBit-io/XrplCSharp XrplCSharp - 10.11.1.0 + 10.12.0.0