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.0 https://github.com/StaticBit-io/XrplCSharp XrplCSharp - 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` — so the capture there is correctness for whoever wires it up, not a live fix + * `ctid` gets a real property on `TransactionStream` instead of a dictionary entry — a wallet asking *which transaction is this* needs it typed, the way `Hash` is + * `TestLedgerClosedRawSurvivesTheStreamPipelineByteForByte` had used `network_id` as its example of a member the model has no place for, asserting the re-serialized output did **not** contain it. That pinned the loss as expected behaviour; the assertion is now flipped to prove the field survives + * **the capture reached 3 result models out of ~40, chosen by which fixtures existed.** That is a test artifact, not a protocol boundary. 45 response projections gained it, bringing the total deriving from `BaseMethodResult` to 47, including `TransactionSummary` — what both `tx` and every `account_tx` entry deserialize into, and the shape whose `status` loss this file previously documented as a known remainder. Excluded, each for a reason: request-side shapes (the `ledger_entry` `*Query` selectors, `Book`/`BookCurrency`, `SourceCurrency`, `TakerAmount`, `AuthorizedCredential`), the two types whose custom converters own the read path (`ServerFeatures`, `GatewayBalancesResponse`), and the stream types already covered through `BaseStream`. + * **The first cut of that exclusion list was wrong, and the way it was wrong is worth recording.** It was built by name, so four types that are read off a response *and fed back into an outgoing one* slipped through: `Methods.Path` (reaches `Payment.Paths` and `PathFindCreateRequest.Paths`), `AuthAccount` (`AMMBid`), and `AuthorizeCredentialEntry`/`AuthorizeCredentialBody` (`DepositPreauth`). Capture on those let a member read from one node's response ride back out inside a transaction the user never put it in — and `StObject.FromJson` passes `signingOnly` only to the top level, so a nested unknown member reaches the displayed `tx_json` but not the signed blob. Show one, sign another: the exact failure this release removes, arriving from the outgoing side. The rule is reachability from the request graph, not the shape of the name + * **`KnownLostMembers` in `TestUResponseFidelity` is now empty** — every member of every captured mainnet response survives the round trip, in both directions. The table stays as the mechanism that keeps it so: a model that stops carrying a field fails the test until someone writes down why. Proven by mutation, removing `[JsonExtensionData]` from `BaseMethodResult` turns it red +* **Four ways `RawTransaction` and the typed `Transaction` could disagree — a wallet showing one transaction and signing another.** Each is the defect this release exists to remove, arriving from the opposite side: + * `JsonSlice.FindTopLevelMember` returned the **first** occurrence of a duplicated key and stopped scanning; System.Text.Json takes the **last**. It now scans to `EndObject` and returns the last, matching the deserializer + * matching is now case-insensitive, because `XrplJsonOptions.Default` sets `PropertyNameCaseInsensitive = true` — a frame carrying `TX_JSON` filled the typed `Transaction` while leaving `RawTransaction` empty + * `TransactionStream.AttachFrame` preferred `tx_json` unconditionally, while the typed side takes whichever envelope appears later (its two setters both do `value ?? _transaction` and run in document order). Both views now resolve the same envelope + * an envelope explicitly set to JSON `null` is skipped rather than winning that ordering rule. `value ?? _transaction` discards a null, so `{"tx_json":{…},"tx_json":null}` leaves the real object on the typed side — resolving the slice to the trailing null emptied `RawTransaction` while `Transaction` still held a payment, showing a wallet nothing while it signed something. Filtering happens per occurrence inside the scan, on the token type, so it composes with the duplicate rule above rather than overriding it + * rippled sends neither duplicate keys nor both envelopes, but the frame reaches this library over the network through arbitrary infrastructure, so the two views must not be able to disagree at all. Each is pinned by a test proven to fail against the previous behaviour +* **What unknown-field capture costs, measured rather than assumed.** The dictionary is not allocated at all when a response has no unrecognized member, so widening the capture to 62 models is free for every response the SDK already models fully. When a member *is* unrecognized, System.Text.Json parses each one into its own `JsonDocument` (pooled buffer, metadata table, key string), which costs about **464 B per captured member** — roughly 15x the 31 bytes of JSON it stands for. That multiplies by nesting depth, which is the part worth knowing: an `account_lines` page of 1 000 trust lines, each carrying one field this SDK does not model, goes from 320 KB retained to **792 KB — 4.33x the JSON's own size**, against 1.75x before, when the field was simply dropped. A single large unknown member is cheaper in proportion at about 1.79x. + * that measurement is why `TransactionStream` declares `account_history_tx_index`/`_boundary`/`_tx_first` as properties rather than leaving them to capture, alongside `ctid`: rippled sends `account_history_tx_index` on every event of such a subscription, and the two flags on some (`_boundary` marks the last transaction of a ledger, `_tx_first` the earliest the account ever had). Captured, the three cost ~796 B on an event carrying all of them — on the one path a wallet cannot avoid +* **Locating a member no longer allocates.** Making the name match case-insensitive (so a key that fills a typed property cannot read as absent) had been written with `reader.GetString()`, which materialized every top-level key of every frame — ~760 B per scan, twice per stream event, on a struct whose whole purpose is to record where a value sits *without* materializing it. `Utf8JsonReader.ValueTextEquals` now answers the ordinary case against the raw bytes and only a differently-cased or escaped key falls through to the allocating comparison, which keeps the result identical to what the serializer does under `PropertyNameCaseInsensitive` +* Three `UnknownFields` declarations were removed from `LOLedger`, `LedgerEntity` and `LedgerBinaryEntity`: their bases had gained the same property, and a duplicate in one hierarchy compiles (CS0108) with System.Text.Json binding the derived one — so the base property stayed `null` forever while the data sat on the subclass. `LedgerClosed` hands callers an `LOBaseLedger`, which would have read empty +* `ErrorResponse` remains the one stream-path type without a whole-event `Raw`: it descends from `BaseResponse`, not `BaseStream`, and gets `RawResult`/`RawId`/`RawRequest` instead. Named here so it is a known boundary rather than an unnoticed gap +* **Stream events reach `IXrplClient`, so the raw bytes on them are usable through the SDK's own contract** (#103). Everything above gives a stream event `Raw` and `RawTransaction` — the point being that a wallet can show a person the transaction a node actually sent before they sign it. Transactions arrive by stream, and the only way to receive one was `client.connection.OnTransaction`: a property of a concrete class, so code written against `IXrplClient` could neither subscribe nor be exercised against a substitute client. The feature existed without a contract to reach it through. + * every event `Connection` raises is declared on `IXrplClient` and forwarded to it. `client.connection.OnX` keeps working unchanged — this adds a surface, it does not move one + * **forwarded, not relayed**, and the distinction is the whole design: `add`/`remove` go straight to the same `Connection`, so the client holds no delegates, no subscriber list of its own, and no subscription that nothing removes. A relaying version would add all three, plus a second place to keep in sync. `TestUUnsubscribingThroughTheInterfaceRemovesTheHandler` pins it by crossing surfaces — subscribe through the client, remove through the connection — because removing through the same surface passes either way + * this is safe because the `Connection` outlives the client: it is assigned once, in the constructor, and `ChangeServer` swaps the *session* inside it rather than the object, so subscriptions survive a server change + * **`IXrplClient.connection` lost its setter** (**breaking**, though nothing in the tree assigned it). With handlers attached through the events above, replacing the connection would strand every one of them on the old object and the stream would go quiet with nothing to show for it +* **Stream messages discarded because handlers fell behind are counted rather than lost in silence** (#105). Events reach handlers through a bounded queue, so a slow handler costs events instead of stalling the socket - the right trade, made in `3c7e38e`. What it left was no trace at all: the queue drops its oldest entry when full, nothing throws, nothing logs, and a consumer building state from the stream drifts from the ledger with no way to tell. + * `Connection.DroppedStreamMessages` and `IXrplClient.DroppedStreamMessages` count the discards, across reconnects and `ChangeServer` alike, since one `Connection` serves them all. Any increase means events arrived and never reached a handler + * `ConnectionOptions.StreamMessageQueueCapacity` (default 10 000, unchanged) sizes the queue - raise it for a consumer that must not miss events and can hold the frames, lower it to bound memory harder + * the counter is incremented from `Channel`'s `itemDropped` callback, which runs inside `TryWrite` on the receive loop. It does nothing but increment for that reason: raising an event or logging there would put consumer code back on the path this queue exists to keep it off - the very failure #105 described but which the queue already prevented + * **browsers no longer take a separate path** (#110). `EnqueueStreamMessage` used to start one fire-and-forget task per frame under WebAssembly, bypassing the queue: the capacity was not consulted, nothing was evicted, the counter stayed at zero however far handlers fell behind, the backlog was bounded by nothing, and concurrent dispatch could hand handlers events out of the order the node sent them. The queue was built for that environment to begin with - `StartMessageProcessor` says "true async support in WebAssembly single-threaded environment" - and measurement confirms it works there. Running the Blazor demo against mainnet: **1 004 transactions over 52 s (19.2 tx/s, 13 ledgers)** through the queue, no console errors, timestamps in order, against **462 over 33 s (13.9 tx/s)** on the bypass. The platforms no longer diverge on the queued path: capacity, eviction counting and single-reader ordering hold on every target, for frames that enter the queue. One window remains and is not platform-specific - StartMessageProcessor runs after the OnConnected callback, so a handler subscribing there can see frames before the channel exists, and those take the direct fallback. Moving the start earlier is blocked by StartPingTimer calling StopPingTimerSync, which stops the processor too; tracked separately + * the issue's premise was checked and does not hold: handlers do not run in the receive loop. A queued frame is dispatched by the background reader, and the receive loop does no more than `TryWrite`; a frame on the fallback path is handed off from the receive loop, but the hand-off yields before it parses anything, so the receive loop does not synchronously deserialize or call handlers there either - the yield promises asynchrony, not a different thread, which is the whole promise needed here. What is real is the silent loss, which is what this addresses +* **Frames from a socket being retired no longer reach handlers as if they were current** (#112). Session identity was threaded into every lifecycle callback - `OnceOpen`, `OnConnectionFailed`, `OnceClose` all compare against `_activeSession.SessionId` - and the message path was the one exception: `ws.OnBinaryMessage` called `IOnMessageFastPath(m)` with no session at all. + * that mattered because retirement is not instant. `RetireOldSessionAsync` runs fire-and-forget beside the new connection and closes the old socket *gracefully*, so it keeps delivering for the length of the close handshake with its callback still attached. Whatever it sent landed in the new session's queue + * after a reconnect those frames are stale. After a `ChangeServer` between networks they are worse than stale: switching mainnet to testnet, a handler that believes it is on testnet could receive the tail of mainnet's stream - transactions for accounts that do not exist there, ledger indexes from another chain, nothing marking them as belonging to the previous connection + * the session now travels with the frame - through the queue, not merely as far as it - and is checked twice: on the way in, to save a queue slot, and again immediately before handlers run, which is the check that guarantees anything. Checking only on the way in cannot: the channel is rebuilt per session under a different lock, and a frame accepted for the live session can still be dequeued long after that session is gone - the queue holds up to `StreamMessageQueueCapacity` frames (10 000 by default) + * matching the id alone would not do either: both paths call `MarkAsRetiring()` while the session is still the active one and only `ConnectInternalAsync` installs its replacement, so frames arriving in that window carry an id that matches. The retiring flag is part of the test, under `_sessionLock` - the same guard `OnceOpen` and the other lifecycle callbacks already use, and the only way `IsRetiring` is published at all + * a `null` session means the caller has none to name (`OnMessage`, which anyone may call), and nothing is rejected in that case + * consequence worth naming: `OnMessage` no longer dispatches inline when there is no processor. It never did on the queued path, so this makes the two agree - but code that called `OnMessage` on an unconnected client and read handler state on the next line was relying on the difference + * **what goes round the queue is now countable.** `FallbackDispatchedStreamMessages` counts frames dispatched outside it - no capacity bound, no eviction counting, no single-reader ordering apply to them. Three things send a frame there: the processor not being up yet, the processor having been stopped, and a refused write. The first is a real window on every connect, and this turns it from something argued about into something measured + * all three counters are declared with default bodies on `IXrplClient`, forwarding to `connection` - the only implementation that means anything. `DroppedStreamMessages` was declared without one in this same unreleased cycle; giving it one too costs nothing and keeps an external implementation of the interface compiling + * **the fallback path hands the frame off before doing any work.** An async method runs on its caller's thread up to the first real await, and the first real await inside `ProcessStreamMessageAsync` comes after `JsonSerializer.Deserialize` - so every frame taking the fallback had its JSON parsed on the receive loop, plus whatever a handler did before its own first await. That is the head-of-line blocking the queue exists to prevent, reintroduced for the startup window, for a stopped processor and for a refused write. A yield at the top of the fallback ends it + * **a frame the channel refuses no longer vanishes.** `TryWrite` was called for its side effect and its result ignored, on the reasoning that a `DropOldest` channel never refuses - which is true of a full queue (it evicts, counts through `itemDropped` and reports success) and false of a completed one. `StopMessageProcessorInternal` completes the writer *after* clearing `_streamMessageChannel`, so whoever read the reference an instant earlier writes into a closed channel and the frame was dropped with nothing to show for it. Not a corner case: `StartPingTimer` tears the processor down and `StartMessageProcessor` builds it again on every connect. Such a frame now takes the fallback path, where it still faces the session check + * `Connection.StaleSessionFramesDropped` counts what was discarded, kept separate from `DroppedStreamMessages` because the two mean different things: a non-zero value here is normal right after a reconnect, while the other means consumers are falling behind. Also on `IXrplClient`, next to `DroppedStreamMessages` - a counter nobody can read is not observability. **New interface member, with a default body** forwarding to `connection`: an external implementation of `IXrplClient` keeps compiling and may override it + ## 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")]`. diff --git a/README.md b/README.md index f2dc6904..084d9cc6 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,12 @@ A pure C# implementation for interacting with the XRP Ledger, the `XrplCSharp` l // create a network client using System.Diagnostics; using Xrpl.Client; +using Xrpl.Models.Methods; +using Xrpl.Wallet; + var client = new XrplClient("wss://s.altnet.rippletest.net:51233"); -client.OnConnected += async () => -{ - Debug.WriteLine("CONNECTED"); -}; await client.Connect(); +Debug.WriteLine("CONNECTED"); // create a wallet on the testnet XrplWallet testWallet = XrplWallet.Generate(); @@ -27,10 +27,31 @@ Debug.WriteLine(testWallet); // look up account info string account = "rBtXmAdEYcno9LWRnAGfT9qBxCeDvuVRZo"; AccountInfoRequest request = new AccountInfoRequest(account); -AccountInfo accountInfo = await client.AccountInfo(request); +AccountInfo accountInfo = await client.AccountInfo(request).Typed(); Debug.WriteLine(accountInfo); ``` +Every request also hands back the bytes the node actually sent, beside the typed +projection. The typed model is lossy by nature — it drops members it has no property +for — so anything that has to *show* or *verify* what a node said reads the raw text: + +```csharp +// continuing from the example above +XrplResponse response = await client.AccountInfo(request); + +AccountInfo typed = response.Result; // the projection +string asTheNodeSentIt = response.Raw.ToString(); // byte for byte, as the node sent it +string status = response.Status; // the envelope reaches you too +``` + +Three ways to read the same call, depending on what you need: + +```csharp +AccountInfo info = await client.AccountInfo(request).Typed(); // the projection alone +var (info, raw) = await client.AccountInfo(request); // both +XrplResponse r = await client.AccountInfo(request); // the whole envelope +``` + ## Installation and supported versions The `Xrpl` library is available on [NuGet](https://www.nuget.org/packages/Xrpl/). Install with `dotnet`: @@ -71,12 +92,12 @@ Use the `Xrpl.Client` library to create a network client for connecting to the X ```csharp using System.Diagnostics; using Xrpl.Client; +using Xrpl.Models.Methods; +using Xrpl.Wallet; + var client = new XrplClient("wss://s.altnet.rippletest.net:51233"); -client.OnConnected += async () => -{ - Debug.WriteLine("CONNECTED"); -}; await client.Connect(); +Debug.WriteLine("CONNECTED"); ``` ### Manage keys and wallets @@ -143,7 +164,7 @@ using Xrpl.Sugar; string classicAddress = "rBtXmAdEYcno9LWRnAGfT9qBxCeDvuVRZo"; AccountInfoRequest request = new AccountInfoRequest(wallet.ClassicAddress); -AccountInfo accountInfo = await client.AccountInfo(request); +AccountInfo accountInfo = await client.AccountInfo(request).Typed(); Payment tx = new Payment() { @@ -162,13 +183,34 @@ In most cases, you can specify the minimum [transaction cost](https://xrpl.org/t ```csharp using System.Diagnostics; -using Xrpl.Models.Transactions; +using Xrpl.Models.Methods; FeeRequest feeRequest = new FeeRequest(); -Fee fee = await client.Fee(feeRequest); +Fee fee = await client.Fee(feeRequest).Typed(); Debug.WriteLine(fee); // 10 ``` +#### Read transaction history through the `I` interfaces + +A transaction that comes back from the ledger — `account_tx`, `tx`, `SubmitAndWait` — is a *response* +type: `NFTokenCreateOfferResponse`, not `NFTokenCreateOffer`. Matching on the request type compiles, +warns about nothing and finds nothing: + +```csharp +foreach (TransactionSummary summary in history.Transactions) +{ + if (summary.Transaction is NFTokenCreateOffer request) { } // never matches + if (summary.Transaction is INFTokenCreateOffer offer) // this is the one + { + Debug.WriteLine(offer.NFTokenID); + } +} +``` + +Request and response types come in pairs that share an `I` interface — use those to read what the +ledger sent, and the request types only to send. The five `ConfidentialMPT` transactions are the +exception: neither half declares an interface, so for those there is nothing to match on yet. + ## Contributing diff --git a/Tests/TestsClients/Blazor-WebAssembly/Pages/Index.razor b/Tests/TestsClients/Blazor-WebAssembly/Pages/Index.razor index 0a213b84..808edad7 100644 --- a/Tests/TestsClients/Blazor-WebAssembly/Pages/Index.razor +++ b/Tests/TestsClients/Blazor-WebAssembly/Pages/Index.razor @@ -382,6 +382,7 @@ private OnConnected _onConnectedHandler; private OnDisconnect _onDisconnectHandler; + private OnSessionEnded _onSessionEndedHandler; private OnPing _onPingHandler; private Action _onConnectionStatusHandler; private OnError _onErrorHandler; @@ -486,6 +487,33 @@ }); }; + // The one signal that covers every way the connection can end. Watching OnDisconnect and + // the RestoringConnection status is not enough: a successful ChangeServer sends neither, + // and the subscriptions - which the node holds against the old connection - are gone all + // the same. That was issue #123, and this page was where it was found. + _onSessionEndedHandler = async (reason, description) => + { + await InvokeAsync(() => + { + AddStatusMessage($"Session ended ({reason}): {description}", MessageType.Warning); + Console.WriteLine($"[SESSION] ended: {reason} - {description}"); + + if (IsStreamSubscribed) + { + _pendingSubscriptionRestore = true; + Console.WriteLine("[SUBSCRIBE DEBUG] Will restore subscriptions on the next connection"); + _streamUiTimer?.Dispose(); + _streamUiTimer = null; + IsStreamSubscribed = false; + } + + _sessionTimer?.Dispose(); + _sessionTimer = null; + + StateHasChanged(); + }); + }; + _onConnectionStatusHandler = (statusInfo) => { InvokeAsync(() => @@ -609,6 +637,7 @@ client.connection.OnConnected += _onConnectedHandler; client.connection.OnDisconnect += _onDisconnectHandler; + client.connection.OnSessionEnded += _onSessionEndedHandler; client.connection.OnPing += _onPingHandler; client.connection.OnConnectionStatus += _onConnectionStatusHandler; client.connection.OnError += _onErrorHandler; @@ -646,6 +675,9 @@ if (_onDisconnectHandler != null) client.connection.OnDisconnect -= _onDisconnectHandler; + if (_onSessionEndedHandler != null) + client.connection.OnSessionEnded -= _onSessionEndedHandler; + if (_onConnectionStatusHandler != null) client.connection.OnConnectionStatus -= _onConnectionStatusHandler; @@ -811,12 +843,12 @@ while (true) { - var response = await client.AccountTransactions( + var response = (await client.AccountTransactions( new Xrpl.Models.Methods.AccountTransactionsRequest(AccountAddress) { Limit = 50, Marker = marker, - }); + })).Result; if (response == null) break; diff --git a/Tests/TestsClients/Test.ClonsoleApp/Program.cs b/Tests/TestsClients/Test.ClonsoleApp/Program.cs index 3771bdf9..e70132fe 100644 --- a/Tests/TestsClients/Test.ClonsoleApp/Program.cs +++ b/Tests/TestsClients/Test.ClonsoleApp/Program.cs @@ -75,7 +75,7 @@ private static async Task Main(string[] args) //await SetSigners(walletMultiSign, walletMultiSigner_1, walletMultiSigner_2); - var features = await client.ServerFeatures(); + var features = await client.ServerFeatures().Typed(); var canBe = features.GetActivated(); var mpts = features.GetByNameContains("mpt"); foreach (var mpt in canBe) @@ -125,7 +125,7 @@ private static async Task CheckLines() Marker = marker, Limit = 200, //IgnoreDefault = true - }); + }).Typed(); lines.AddRange(all.TrustLines); marker = all.Marker; } @@ -173,8 +173,8 @@ private static async Task TestPayment() TakerPays = payment.DeliverMin, Flags = OfferCreateFlags.tfImmediateOrCancel | OfferCreateFlags.tfSell, }; - var simulate = await client.Simulate(new SimulateRequest() { Transaction = payment }); - var simulate2 = await client.Simulate(new SimulateRequest() { Transaction = offer }); + var simulate = await client.Simulate(new SimulateRequest() { Transaction = payment }).Typed(); + var simulate2 = await client.Simulate(new SimulateRequest() { Transaction = offer }).Typed(); var changes = BalanceChanges.GetBalanceChanges(simulate.Meta); var changes2 = BalanceChanges.GetBalanceChanges(simulate2.Meta); var jsonSimulate = JsonSerializer.Serialize(simulate2); @@ -213,7 +213,7 @@ private static async Task InitForDataForTest() Role = RoleType.Gateway, Transactions = true, Limit = 100 - }); + }).Typed(); foreach (var request in noRippleCheck.Transactions) { Console.WriteLine(request.ToJson()); @@ -346,9 +346,26 @@ private static async Task TestReconnection() await Task.Run(Console.ReadLine); await client.ChangeServer(server); - ServerState? serverInfo = await client.ServerState(new ServerStateRequest()); - string lineReserveFee = serverInfo.State.ValidatedLedger.ReserveInc.ToString(); - string accReserveFee = serverInfo.State.ValidatedLedger.ReserveBase.ToString(); + ServerState? serverInfo = await client.ServerState(new ServerStateRequest()).Typed(); + // Check the whole path, not just the leaves: server_state.validated_ledger is the + // only place these live, and a response missing any level of it is malformed the + // same way a missing reserve is. Dereferencing first would turn that into a + // NullReferenceException instead of the validation error below. + if (serverInfo?.State?.ValidatedLedger is not { } validatedLedger) + { + throw new ValidationException("server_state response did not include a validated ledger."); + } + + // reserve_base/reserve_inc are nullable (see Balances.GetXrpFreeBalance) — a + // missing value means a malformed node response, not a zero reserve. + uint? reserveInc = validatedLedger.ReserveInc; + uint? reserveBase = validatedLedger.ReserveBase; + if (reserveInc == null || reserveBase == null) + { + throw new ValidationException("server_state response did not include the validated ledger's reserve_base/reserve_inc."); + } + string lineReserveFee = reserveInc.Value.ToString(); + string accReserveFee = reserveBase.Value.ToString(); var _lineReserveFee = new Currency { Value = lineReserveFee, @@ -482,7 +499,7 @@ private static async Task Simulate() new SimulateRequest() { Transaction = tx - }); + }).Typed(); if (result.TxJson is Payment { } payment) { @@ -527,7 +544,7 @@ private static async Task SubmitTestTx() var wallet = XrplWallet.FromSeed(seed); var request = new AccountInfoRequest(wallet.ClassicAddress); - var accountInfo = await client.AccountInfo(request); + var accountInfo = await client.AccountInfo(request).Typed(); // prepare the transaction // the amount is expressed in drops, not XRP @@ -561,7 +578,7 @@ private static async Task TestAmm() Console.WriteLine("NEXT"); var request = new AccountInfoRequest(wallet.ClassicAddress); - var accountInfo = await client.AccountInfo(request); + var accountInfo = await client.AccountInfo(request).Typed(); // prepare the transaction // the amount is expressed in drops, not XRP @@ -959,7 +976,7 @@ private static async Task MultiSignTest() private static async Task SetSigners(XrplWallet owner, XrplWallet signer1, XrplWallet signer2) { // Проверьте: у owner достаточно резерва на SignerList (≈ +2 XRP * на подпись). - var acc = await client.AccountInfo(new AccountInfoRequest(owner.ClassicAddress)); + var acc = await client.AccountInfo(new AccountInfoRequest(owner.ClassicAddress)).Typed(); // Создаём/обновляем список подписантов (2 из 2) var sls = new SignerListSet @@ -981,7 +998,7 @@ private static async Task SetSigners(XrplWallet owner, XrplWallet signer1, XrplW private static async Task DisableMaster(XrplWallet owner) { AccountInfo acc; - acc = await client.AccountInfo(new AccountInfoRequest(owner.ClassicAddress)); + acc = await client.AccountInfo(new AccountInfoRequest(owner.ClassicAddress)).Typed(); var disableMaster = new AccountSet { diff --git a/Tests/TestsClients/Test.ClonsoleApp/TestAccountBuilder.cs b/Tests/TestsClients/Test.ClonsoleApp/TestAccountBuilder.cs index 0cfe75a6..259ea6e2 100644 --- a/Tests/TestsClients/Test.ClonsoleApp/TestAccountBuilder.cs +++ b/Tests/TestsClients/Test.ClonsoleApp/TestAccountBuilder.cs @@ -383,7 +383,7 @@ private async Task EnableDefaultRippleAsync() try { AccountInfoRequest infoRequest = new AccountInfoRequest(IssuerAccount.ClassicAddress); - AccountInfo info = await _client.AccountInfo(infoRequest); + AccountInfo info = await _client.AccountInfo(infoRequest).Typed(); if (info.AccountFlags.DefaultRipple) { Console.WriteLine("[TestAccountBuilder] DefaultRipple: already enabled, skipping"); @@ -421,7 +421,7 @@ private async Task EnableIssuer2FlagsAsync() try { AccountInfoRequest infoRequest = new AccountInfoRequest(Issuer2Account.ClassicAddress); - AccountInfo info = await _client.AccountInfo(infoRequest); + AccountInfo info = await _client.AccountInfo(infoRequest).Typed(); needDefaultRipple = !info.AccountFlags.DefaultRipple; needTrustLineLocking = !info.AccountFlags.AllowTrustLineLocking; } @@ -475,7 +475,7 @@ private async Task EnableIssuer3FlagsAsync() try { AccountInfoRequest infoRequest = new AccountInfoRequest(Issuer3Account.ClassicAddress); - AccountInfo info = await _client.AccountInfo(infoRequest); + AccountInfo info = await _client.AccountInfo(infoRequest).Typed(); needDefaultRipple = !info.AccountFlags.DefaultRipple; needRequireAuth = !info.AccountFlags.RequireAuthorization; needTrustLineLocking = !info.AccountFlags.AllowTrustLineLocking; @@ -576,7 +576,7 @@ private async Task AmmExistsAsync(string currencyCode, string issuer) Asset = new Xrpl.Models.Common.Common.IssuedCurrency { Currency = "XRP" }, Asset2 = new Xrpl.Models.Common.Common.IssuedCurrency { Currency = currencyCode, Issuer = issuer } }; - var response = await _client.AmmInfo(request); + var response = await _client.AmmInfo(request).Typed(); return response?.Amm != null; } catch @@ -590,7 +590,7 @@ private async Task SignerListExistsAsync() try { var request = new AccountInfoRequest(_primaryAccount.ClassicAddress) { SignerLists = true }; - var response = await _client.AccountInfo(request); + var response = await _client.AccountInfo(request).Typed(); return response.SignerLists != null && response.SignerLists.Length > 0; } catch @@ -604,7 +604,7 @@ private async Task GetTicketCountAsync() try { var request = new AccountObjectsRequest(_primaryAccount.ClassicAddress) { Type = LedgerEntryType.Ticket }; - var response = await _client.AccountObjects(request); + var response = await _client.AccountObjects(request).Typed(); return response.AccountObjectList?.Count ?? 0; } catch @@ -618,7 +618,7 @@ private async Task MptIssuanceExistsAsync() try { var request = new AccountObjectsRequest(IssuerAccount.ClassicAddress) { Type = LedgerEntryType.MPTokenIssuance }; - var response = await _client.AccountObjects(request); + var response = await _client.AccountObjects(request).Typed(); return response.AccountObjectList?.Count > 0; } catch @@ -920,7 +920,7 @@ private async Task CreateNFTOffersAsync(int i) try { - var nftsResponse = await _client.AccountNFTs(new AccountNFTsRequest(_primaryAccount.ClassicAddress)); + var nftsResponse = await _client.AccountNFTs(new AccountNFTsRequest(_primaryAccount.ClassicAddress)).Typed(); if (nftsResponse.NFTs == null || nftsResponse.NFTs.Count == 0) { Console.WriteLine("[TestAccountBuilder] No NFTs found to create offers for"); diff --git a/Tests/Xrpl.Tests/BinaryCodec/TestUStrictNestedFields.cs b/Tests/Xrpl.Tests/BinaryCodec/TestUStrictNestedFields.cs new file mode 100644 index 00000000..144644fd --- /dev/null +++ b/Tests/Xrpl.Tests/BinaryCodec/TestUStrictNestedFields.cs @@ -0,0 +1,367 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System.Collections.Generic; +using System.Text.Json.Nodes; + +using Xrpl.BinaryCodec; +using Xrpl.BinaryCodec.Types; +using Xrpl.Models.Utils; +using Xrpl.Wallet; + +namespace XrplTests.BinaryCodec; + +/// +/// A member this codec does not know is refused at every level of a transaction being signed, not +/// only at the top. +/// +/// +/// The top level always failed loudly. One level down it did not: nested objects reached +/// StObject.FromJson through a dispatch table whose delegate carries no strictness flag, so +/// they went through the lenient overload and the member was dropped without a word. A caller could +/// be shown a transaction carrying 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. Nothing +/// reported anything. +/// +/// rippled refuses such a transaction outright: STParsedJSON::parseObject recurses and +/// answers unknownField at every level. +/// +/// +[TestClass] +public class TestUStrictNestedFields +{ + private const string Seed = "snGHNrPbHrdUcszeuDEigMdC1Lyyd"; + + private static Dictionary Payment(XrplWallet wallet) => new Dictionary + { + { "TransactionType", "Payment" }, + { "Account", wallet.ClassicAddress }, + { "Destination", "rQ3fNyLjbvcDaPNS4EAJY8aT9zR3uGk17c" }, + { "Amount", "1000" }, + { "Fee", "12" }, + { "Sequence", 1u }, + { "LastLedgerSequence", 100u }, + }; + + /// + /// A member inside an object inside an array - the shape the report was filed against. + /// + /// + /// Memos is an array of objects, so Memos[0].Memo.MemoBogusField sits two levels + /// below the top and needs both the array and the object to carry the flag down. + /// + [TestMethod] + public void TestUUnknownFieldInsideAMemoIsRefused() + { + XrplWallet wallet = XrplWallet.FromSeed(Seed); + Dictionary tx = Payment(wallet); + tx["Memos"] = new List + { + new Dictionary + { + { + "Memo", new Dictionary + { + { "MemoData", "72656E74" }, + { "MemoBogusField", "1" }, + } + }, + }, + }; + + InvalidJsonException error = Assert.ThrowsExactly( + () => wallet.Sign(tx), + "signing accepted a member the codec does not know, and would have dropped it from the blob"); + + StringAssert.Contains(error.Message, "MemoBogusField", + "the error has to name the member, or a caller cannot tell which one it was"); + } + + /// + /// The top level, which always behaved - kept so the recursion cannot be "fixed" by moving the + /// check downward. + /// + [TestMethod] + public void TestUUnknownFieldAtTheTopLevelIsRefused() + { + XrplWallet wallet = XrplWallet.FromSeed(Seed); + Dictionary tx = Payment(wallet); + tx["BogusTop"] = "1"; + + InvalidJsonException error = Assert.ThrowsExactly(() => wallet.Sign(tx)); + StringAssert.Contains(error.Message, "BogusTop"); + } + + /// + /// One level down, without an array in the way. + /// + [TestMethod] + public void TestUUnknownFieldInsideAPlainObjectIsRefused() + { + XrplWallet wallet = XrplWallet.FromSeed(Seed); + Dictionary tx = Payment(wallet); + tx["Amount"] = new Dictionary + { + { "currency", "USD" }, + { "issuer", "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" }, + { "value", "100" }, + }; + tx["Memos"] = new List + { + new Dictionary + { + { "Memo", new Dictionary { { "MemoData", "72656E74" } } }, + { "MemoBogusSibling", "1" }, + }, + }; + + InvalidJsonException error = Assert.ThrowsExactly(() => wallet.Sign(tx)); + StringAssert.Contains(error.Message, "MemoBogusSibling"); + } + + /// + /// A signed transaction with nothing unknown in it still signs. + /// + /// + /// Without this, a check that refused everything would pass every test above while making the + /// SDK unable to sign at all. + /// + [TestMethod] + public void TestUATransactionWithKnownFieldsOnlyStillSigns() + { + XrplWallet wallet = XrplWallet.FromSeed(Seed); + Dictionary tx = Payment(wallet); + tx["Memos"] = new List + { + new Dictionary + { + { "Memo", new Dictionary { { "MemoData", "72656E74" } } }, + }, + }; + + SignatureResult signed = wallet.Sign(tx); + + Assert.IsFalse(string.IsNullOrEmpty(signed.TxBlob), "a well-formed transaction must still sign"); + Assert.IsFalse(string.IsNullOrEmpty(signed.Hash)); + } + + /// + /// The id an outer Batch signs over is computed strictly. + /// + /// + /// The worst of the three shapes in the report. ComputeInnerTxId parsed leniently, so a + /// member the codec did not know was dropped from the bytes being hashed - and that hash is + /// what the outer Batch signature commits to. The signature fixed an inner transaction other + /// than the one the caller was shown, with nothing anywhere saying so. + /// + /// Strict here does not mean signing-only: an id covers the whole transaction, so filtering to + /// signing fields would hash something else again. + /// + /// + [TestMethod] + public void TestUInnerBatchTxIdRefusesAnUnknownField() + { + JsonObject inner = new JsonObject + { + ["TransactionType"] = "Payment", + ["Account"] = "rQ3fNyLjbvcDaPNS4EAJY8aT9zR3uGk17c", + ["Destination"] = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + ["Amount"] = "1000", + ["Fee"] = "0", + ["Sequence"] = 1, + ["SigningPubKey"] = "", + ["FutureField"] = "1", + }; + + InvalidJsonException error = Assert.ThrowsExactly( + () => inner.ComputeInnerTxId(), + "the id the outer signature commits to was computed over a transaction with the member silently removed"); + + StringAssert.Contains(error.Message, "FutureField"); + } + + /// + /// An inner transaction with nothing unknown in it still gets an id. + /// + [TestMethod] + public void TestUInnerBatchTxIdStillComputedForKnownFields() + { + JsonObject inner = new JsonObject + { + ["TransactionType"] = "Payment", + ["Account"] = "rQ3fNyLjbvcDaPNS4EAJY8aT9zR3uGk17c", + ["Destination"] = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + ["Amount"] = "1000", + ["Fee"] = "0", + ["Sequence"] = 1, + ["SigningPubKey"] = "", + }; + + string id = inner.ComputeInnerTxId(); + + Assert.AreEqual(64, id.Length, "a transaction id is 32 bytes of hex"); + } + + /// + /// A member the codec does not know inside a path step is refused. + /// + /// + /// Path steps are not StObjects - PathHop parses them itself, reading four named + /// members and, until now, walking past anything else. So the recursion through objects and + /// arrays did not reach them: a member here was still dropped from the bytes being signed. + /// + [TestMethod] + public void TestUUnknownMemberInAPathStepIsRefused() + { + XrplWallet wallet = XrplWallet.FromSeed(Seed); + Dictionary tx = Payment(wallet); + tx["Amount"] = new Dictionary + { + { "currency", "USD" }, + { "issuer", "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" }, + { "value", "100" }, + }; + tx["Paths"] = new List + { + new List + { + new Dictionary + { + { "account", "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" }, + { "bogus_step_member", "1" }, + }, + }, + }; + + InvalidJsonException error = Assert.ThrowsExactly(() => wallet.Sign(tx)); + StringAssert.Contains(error.Message, "bogus_step_member"); + } + + /// + /// A path step carrying type still parses, because that is what the node sends. + /// + /// + /// The trap in refusing unknown members here. ripple_path_find answers with a + /// type on every step, this SDK declares it on PathStep and emits it back out of + /// PathHop.ToJson, 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, and this test is what says so out loud. + /// + [TestMethod] + public void TestUPathStepFromTheNodeStillSigns() + { + XrplWallet wallet = XrplWallet.FromSeed(Seed); + Dictionary tx = Payment(wallet); + tx["Amount"] = new Dictionary + { + { "currency", "USD" }, + { "issuer", "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" }, + { "value", "100" }, + }; + tx["Paths"] = new List + { + new List + { + new Dictionary + { + { "account", "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" }, + { "type", 1 }, + { "type_hex", "0000000000000001" }, + }, + }, + }; + + SignatureResult signed = wallet.Sign(tx); + + Assert.IsFalse(string.IsNullOrEmpty(signed.TxBlob), + "a path taken from a ripple_path_find answer must still sign"); + } + + /// + /// The MPT form of an Issue counts its members, like the two forms beside it always did. + /// + [TestMethod] + public void TestUUnknownMemberInAnMptIssueIsRefused() + { + JsonObject issue = new JsonObject + { + ["mpt_issuance_id"] = "00000012D444B0B85E1FB7C22C0B7A8CE9C5AA5CE68B96A3", + ["bogus"] = "1", + }; + + Assert.ThrowsExactly( + () => Issue.FromJson(issue), + "the MPT form was the one shape of Issue that walked past a member it did not know"); + } + + /// + /// An XChainBridge carries exactly its four members. + /// + [TestMethod] + public void TestUUnknownMemberInAnXChainBridgeIsRefused() + { + JsonObject bridge = new JsonObject + { + ["LockingChainDoor"] = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + ["LockingChainIssue"] = new JsonObject { ["currency"] = "XRP" }, + ["IssuingChainDoor"] = "rQ3fNyLjbvcDaPNS4EAJY8aT9zR3uGk17c", + ["IssuingChainIssue"] = new JsonObject { ["currency"] = "XRP" }, + ["bogus"] = "1", + }; + + Assert.ThrowsExactly(() => XChainBridgeType.FromJson(bridge)); + } + + /// + /// Swapping one of the four members for an unknown one is still refused. + /// + /// + /// Counting alone would let this through: three of the four plus one member that does not + /// belong also comes to four. The missing one would then reach AccountId.FromJson as + /// null, and the caller would be told whatever that makes of nothing instead of which member + /// is wrong. + /// + [TestMethod] + public void TestUXChainBridgeWithTheRightCountButTheWrongMembersIsRefused() + { + JsonObject bridge = new JsonObject + { + ["LockingChainDoor"] = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + ["LockingChainIssue"] = new JsonObject { ["currency"] = "XRP" }, + ["IssuingChainDoor"] = "rQ3fNyLjbvcDaPNS4EAJY8aT9zR3uGk17c", + ["bogus"] = "1", + }; + + InvalidJsonException error = Assert.ThrowsExactly( + () => XChainBridgeType.FromJson(bridge), + "four members of the wrong names passed a check that only counted them"); + + StringAssert.Contains(error.Message, "IssuingChainIssue", + "the error should say which members a bridge is supposed to have"); + } + + /// + /// Encode stays lenient, deliberately. + /// + /// + /// The asymmetry is the decision, not an oversight. Encode is used to read as much as to + /// write: IsSigned, IsAccountDelete and GetLastLedgerSequence run a + /// transaction through the codec to answer a question about it, and HashSignedTx hashes + /// transactions that came from the node. Making it strict would turn "does this look signed?" + /// into an exception, and would fail on any response carrying a field newer than this SDK's + /// definitions.json - the forward compatibility the raw-JSON work exists to keep. + /// + [TestMethod] + public void TestUEncodeStillDropsUnknownFieldsWithoutComplaint() + { + XrplWallet wallet = XrplWallet.FromSeed(Seed); + Dictionary tx = Payment(wallet); + tx["SigningPubKey"] = ""; + tx["BogusTop"] = "1"; + + string blob = XrplBinaryCodec.Encode(tx); + + Assert.IsFalse(string.IsNullOrEmpty(blob), + "Encode answers a question about a transaction; it must not start throwing on unknown members"); + } +} diff --git a/Tests/Xrpl.Tests/Client/BenchmarkLedgerDataCrawl.cs b/Tests/Xrpl.Tests/Client/BenchmarkLedgerDataCrawl.cs index 18521c80..5d323a0d 100644 --- a/Tests/Xrpl.Tests/Client/BenchmarkLedgerDataCrawl.cs +++ b/Tests/Xrpl.Tests/Client/BenchmarkLedgerDataCrawl.cs @@ -175,7 +175,7 @@ private static async Task RequestUntypedPageAsync(XrplClient client) Binary = true, Limit = 2048 }) - .ConfigureAwait(false); + .Typed().ConfigureAwait(false); if (result.ValueKind != JsonValueKind.Object || !result.TryGetProperty("state", out JsonElement state)) { @@ -198,7 +198,7 @@ private static async Task RequestPageAsync(XrplClient client) ["limit"] = 2048 }; - Dictionary response = await client.Request(request).ConfigureAwait(false); + Dictionary response = await client.Request(request).Typed().ConfigureAwait(false); if (response == null) { throw new InvalidOperationException("empty ledger_data response"); diff --git a/Tests/Xrpl.Tests/Client/Exceptions/TestIXrplErrorClassifier.cs b/Tests/Xrpl.Tests/Client/Exceptions/TestIXrplErrorClassifier.cs index 3e6f3acb..d7ac7231 100644 --- a/Tests/Xrpl.Tests/Client/Exceptions/TestIXrplErrorClassifier.cs +++ b/Tests/Xrpl.Tests/Client/Exceptions/TestIXrplErrorClassifier.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Text.Json; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -7,6 +8,7 @@ using Xrpl.Client; using Xrpl.Client.Exceptions; +using Xrpl.Client.Json; using Xrpl.Models.Common; using Xrpl.Models.Ledger; using Xrpl.Models.Methods; @@ -93,7 +95,7 @@ public async Task Classify_TxnNotFound_ReturnsExpectedInfo() TxRequest request = new TxRequest(transactionHash); - RippledException exception = await CatchRippledException(() => client.Tx(request)); + RippledException exception = await CatchRippledException(() => client.TxV1(request)); XrplErrorInfo info = XrplErrorClassifier.Classify(exception); Assert.AreEqual(XrplErrorCodes.TxnNotFound, info.RawError); @@ -1051,19 +1053,31 @@ public void Classify_JObjectRequest_UsesRequestDirectly() Assert.AreEqual("[\"rHot1\"]", info.FieldValue); } + /// + /// Builds an the same way does: through + /// the wire form. RequestSlice/RawRequest only exist as bounds into a frame the + /// envelope was parsed from - was removed together with + /// that - so a hand-built instance with no frame would report an empty request regardless of + /// what is passed here. + /// private static ErrorResponse CreateErrorResponse( string errorCode, object? request = null, string? errorMessage = null, List? warnings = null) { - return new ErrorResponse + var envelope = new { - Error = errorCode, - ErrorMessage = errorMessage ?? errorCode, - Request = request ?? new { }, - Warnings = warnings + error = errorCode, + error_message = errorMessage ?? errorCode, + request = request ?? new { }, + warnings }; + + byte[] frame = JsonSerializer.SerializeToUtf8Bytes(envelope, XrplJsonOptions.Default); + ErrorResponse response = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default)!; + response.AttachFrame(frame); + return response; } private static RippleResponseWarning CreateWarning(uint id, string message) diff --git a/Tests/Xrpl.Tests/Client/Exceptions/TestUTransactionFailedException.cs b/Tests/Xrpl.Tests/Client/Exceptions/TestUTransactionFailedException.cs new file mode 100644 index 00000000..0696e714 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/Exceptions/TestUTransactionFailedException.cs @@ -0,0 +1,123 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; + +using Xrpl.Client.Exceptions; +using Xrpl.Models.Methods; + +namespace Xrpl.Tests.Client.Exceptions +{ + /// + /// The typed outcome of a failed submission - issue #131. + /// + /// + /// What a caller does next is decided by the class of the result code, and those classes mean + /// entirely different things: tem is a malformed request to fix before resending, + /// tec was applied to a ledger with the fee taken, ter may work later. Reading + /// that out of the message worked until the first transaction whose text contained the + /// substring somewhere else. + /// + [TestClass] + public class TestUTransactionFailedException + { + /// + /// A failure that reached a ledger carries the transaction with it. + /// + [TestMethod] + public void TestUAFailureInALedgerCarriesItsTransaction() + { + TransactionSummary summary = new TransactionSummary(); + + TransactionFailedException error = new TransactionFailedException( + "Final tx result is not success: tecDUPLICATE", + engineResult: "tecDUPLICATE", + hash: "5F8A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D4E5F60718293A4B5C6D7E8", + result: summary); + + Assert.AreEqual("tecDUPLICATE", error.EngineResult); + Assert.AreSame(summary, error.Result); + Assert.IsTrue(error.ReachedLedger, "A tec was applied: the fee is gone and the transaction can be looked up."); + Assert.IsFalse(string.IsNullOrEmpty(error.Hash), "The hash is what makes 'show me the transaction' possible."); + } + + /// + /// One refused before a ledger carries no transaction, and says so. + /// + /// + /// The distinction is the point of : + /// nothing was charged and there is nothing to show, which is a different event for the + /// caller even though it arrives as the same kind of failure. + /// + [TestMethod] + public void TestUARefusalBeforeALedgerHasNothingToShow() + { + TransactionFailedException error = new TransactionFailedException( + "Final tx result is not success: temBAD_FEE", + engineResult: "temBAD_FEE", + hash: "5F8A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D4E5F60718293A4B5C6D7E8"); + + Assert.AreEqual("temBAD_FEE", error.EngineResult); + Assert.IsNull(error.Result); + Assert.IsFalse(error.ReachedLedger, "A tem never reached a ledger, so no fee was taken."); + } + + /// + /// A tec reported before the ledger closed is still a tec: applied, fee taken, + /// and no summary to hand over yet. + /// + /// + /// + /// This is the case the whole design of + /// turns on, and the one an integration run found: the same failure is reported at one of + /// two moments depending on whether the ledger closed before the first poll. Reading the + /// answer off would make it depend on which + /// moment won that race, and a caller would be told the fee was not taken when it was. + /// + /// + /// Without this test the two above pass either way - a tec with a summary and a + /// tem without one give the same answer under both readings. Only this combination + /// tells them apart, which is why it is written down rather than left to the integration + /// test, whose branch depends on ledger timing. + /// + /// + [TestMethod] + public void TestUATecWithoutASummaryStillReachedALedger() + { + TransactionFailedException error = new TransactionFailedException( + "Final tx result is not success: tecNO_DST_INSUF_XRP", + engineResult: "tecNO_DST_INSUF_XRP", + hash: "5F8A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D4E5F60718293A4B5C6D7E8"); + + Assert.IsNull(error.Result, "Precondition: this is the moment before validation."); + Assert.IsTrue( + error.ReachedLedger, + "A tec was applied whether or not the summary has arrived - the fee is gone either way."); + Assert.IsFalse( + string.IsNullOrEmpty(error.Hash), + "And the hash, which is what an explorer needs, is there in this case too."); + } + + /// + /// Existing code keeps working: the type and the message are both unchanged from a + /// consumer's point of view. + /// + /// + /// This is the whole reason the type derives from rather than + /// standing on its own, and the reason the message was left exactly as it was rather than + /// 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. + /// + [TestMethod] + public void TestUItIsStillARippleExceptionWithTheSameMessage() + { + Exception error = new TransactionFailedException( + "Final tx result is not success: tecDUPLICATE", + engineResult: "tecDUPLICATE", + hash: "5F8A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D4E5F60718293A4B5C6D7E8"); + + Assert.IsInstanceOfType(error, "catch (RippleException) must keep catching this."); + Assert.AreEqual("Final tx result is not success: tecDUPLICATE", error.Message); + } + } +} diff --git a/Tests/Xrpl.Tests/Client/Json/Converters/FromStringDateTimeConverterTests.cs b/Tests/Xrpl.Tests/Client/Json/Converters/FromStringDateTimeConverterTests.cs index cc480d4d..c27b3df4 100644 --- a/Tests/Xrpl.Tests/Client/Json/Converters/FromStringDateTimeConverterTests.cs +++ b/Tests/Xrpl.Tests/Client/Json/Converters/FromStringDateTimeConverterTests.cs @@ -48,6 +48,48 @@ public void Read_IsoString_ReturnsDateTime() Assert.AreEqual(15, result.Timestamp.Value.Day); } + /// + /// Real mainnet close_time_iso values are "Z"-suffixed (Zulu time), not the numeric-offset + /// form covered by . Value captured from a live + /// tx response (rippled hash E08D6E9754025BA2534A78707605E0601F03ACE063687A0CA1BDDACFCD1698C7). + /// Before the converter accepted "K" instead of "zzz", TryParseExact failed on this shape and + /// the converter silently returned null — every close_time_iso on a real response was lost, + /// even on the models that already declared the property. + /// + [TestMethod] + public void Read_ZSuffixedIsoString_ReturnsDateTime() + { + string json = "{\"Timestamp\": \"2013-03-12T23:16:50Z\"}"; + Model result = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + Assert.IsNotNull(result.Timestamp, "\"Z\"-suffixed timestamps must parse, not silently become null"); + Assert.AreEqual(new DateTime(2013, 3, 12, 23, 16, 50, DateTimeKind.Utc), result.Timestamp.Value); + Assert.AreEqual(DateTimeKind.Utc, result.Timestamp.Value.Kind); + } + + /// + /// A numeric offset that is not already UTC must be converted, not merely reinterpreted as UTC. + /// + [TestMethod] + public void Read_NonUtcOffset_AdjustsToUtc() + { + string json = "{\"Timestamp\": \"2013-03-12T23:16:50+02:00\"}"; + Model result = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + Assert.IsNotNull(result.Timestamp); + Assert.AreEqual(new DateTime(2013, 3, 12, 21, 16, 50, DateTimeKind.Utc), result.Timestamp.Value); + } + + /// Round-trip must not regress to the old "+00:00" write format silently losing "Z" input. + [TestMethod] + public void RoundTrip_ZSuffixedIsoString_WritesZSuffixBack() + { + string json = "{\"Timestamp\": \"2013-03-12T23:16:50Z\"}"; + Model result = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + + string output = JsonSerializer.Serialize(result, XrplJsonOptions.Default); + + StringAssert.Contains(output, "2013-03-12T23:16:50Z"); + } + [TestMethod] public void Read_InvalidString_ReturnsNull() { @@ -79,4 +121,57 @@ public void RoundTrip_IsoFormat_PreservesDate() Assert.AreEqual(original.Month, deserialized.Timestamp.Value.Month); Assert.AreEqual(original.Day, deserialized.Timestamp.Value.Day); } + + /// + /// A Utc-kind value is the baseline Read itself always produces: "K" must emit the "Z" suffix, + /// not a "+00:00" offset. + /// + [TestMethod] + public void Write_UtcKind_WritesZSuffix() + { + DateTime date = new DateTime(2024, 6, 15, 12, 30, 0, DateTimeKind.Utc); + Model model = new Model { Timestamp = date }; + string json = JsonSerializer.Serialize(model, XrplJsonOptions.Default); + StringAssert.Contains(json, "2024-06-15T12:30:00Z"); + } + + /// + /// "K" emits no zone marker at all for DateTimeKind.Unspecified - neither "Z" nor a numeric + /// offset - so an unset Kind must be normalized to UTC before formatting, mirroring + /// DateTimeStyles.AssumeUniversal on the Read side. A caller assigning a DateTime by hand + /// (Read itself never produces Unspecified) is exactly the case this covers. + /// + [TestMethod] + public void Write_UnspecifiedKind_TreatedAsUtc() + { + DateTime date = new DateTime(2024, 6, 15, 12, 30, 0, DateTimeKind.Unspecified); + Model model = new Model { Timestamp = date }; + string json = JsonSerializer.Serialize(model, XrplJsonOptions.Default); + StringAssert.Contains(json, "2024-06-15T12:30:00Z"); + } + + /// + /// A Local-kind value must be converted to UTC before formatting, not written out with a local + /// offset that Read (which always normalizes to UTC) would then interpret differently on the + /// way back in. + /// + [TestMethod] + public void Write_LocalKind_ConvertsToUtc() + { + DateTime utc = new DateTime(2024, 6, 15, 12, 30, 0, DateTimeKind.Utc); + DateTime local = utc.ToLocalTime(); + Model model = new Model { Timestamp = local }; + string json = JsonSerializer.Serialize(model, XrplJsonOptions.Default); + + // A round-trip alone would still pass if Write emitted a numeric offset ("+00:00") + // instead of "Z": Read normalizes either shape back to the same UTC instant, and on a + // UTC test agent the local offset can even be zero, so the round-trip gives no signal at + // all. Assert on the wire format directly - "Z" is what rippled actually sends. + StringAssert.Contains(json, "2024-06-15T12:30:00Z"); + + Model deserialized = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + Assert.IsNotNull(deserialized.Timestamp); + Assert.AreEqual(DateTimeKind.Utc, deserialized.Timestamp.Value.Kind); + Assert.AreEqual(utc, deserialized.Timestamp.Value); + } } diff --git a/Tests/Xrpl.Tests/Client/Json/Converters/JsonSliceConverterTests.cs b/Tests/Xrpl.Tests/Client/Json/Converters/JsonSliceConverterTests.cs new file mode 100644 index 00000000..d051c2a4 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/Json/Converters/JsonSliceConverterTests.cs @@ -0,0 +1,147 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Xrpl.Client.Json; +using Xrpl.Client.Json.Converters; + +namespace XrplTests.Client.Json.Converters; + +/// +/// Pins that the response envelope records where result sits in the frame instead of +/// materializing it. The slice has to be byte-exact: everything downstream — the typed +/// deserialization and the raw JSON handed to consumers — is cut from it. +/// +[TestClass] +public class TestUJsonSliceConverter +{ + private sealed class SliceProbe + { + [JsonPropertyName("result")] + [JsonConverter(typeof(JsonSliceConverter))] + public JsonSlice Result { get; set; } + } + + [TestMethod] + public void TestUSliceMatchesResultSubtreeExactly() + { + // Deliberately irregular whitespace: the slice must reproduce the bytes as sent, + // not a normalized rendering of them. + string message = "{\"id\":\"7\", \"status\":\"success\", \"result\": {\"a\" : 1,\"b\":[2, 3]} , \"warning\":\"load\"}"; + byte[] frame = Encoding.UTF8.GetBytes(message); + + SliceProbe probe = JsonSerializer.Deserialize(frame, new JsonSerializerOptions()); + + string expected = "{\"a\" : 1,\"b\":[2, 3]}"; + string actual = Encoding.UTF8.GetString(frame, probe.Result.Offset, probe.Result.Length); + Assert.AreEqual(expected, actual); + } + + [TestMethod] + public void TestUSliceIsEmptyWhenResultAbsent() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"id\":\"7\",\"status\":\"success\"}"); + + SliceProbe probe = JsonSerializer.Deserialize(frame, new JsonSerializerOptions()); + + Assert.IsTrue(probe.Result.IsEmpty); + } + + [TestMethod] + public void TestUSliceCoversExplicitNull() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"id\":\"7\",\"result\":null}"); + + SliceProbe probe = JsonSerializer.Deserialize(frame, new JsonSerializerOptions()); + + Assert.AreEqual("null", Encoding.UTF8.GetString(frame, probe.Result.Offset, probe.Result.Length)); + } + + /// + /// The only test that tells a real Skip() from counting braces by hand: both the brace and + /// the quote live inside a string value. + /// + [TestMethod] + public void TestUSliceSkipsBracesInsideStrings() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"result\":{\"m\":\"}{\\\"\"},\"x\":2}"); + + SliceProbe probe = JsonSerializer.Deserialize(frame, new JsonSerializerOptions()); + + Assert.AreEqual("{\"m\":\"}{\\\"\"}", Encoding.UTF8.GetString(frame, probe.Result.Offset, probe.Result.Length)); + } + + /// Offsets are counted in bytes, not characters. + [TestMethod] + public void TestUSliceOffsetsAreByteBased() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"pad\":\"é中😀\",\"result\":{\"a\":1}}"); + + SliceProbe probe = JsonSerializer.Deserialize(frame, new JsonSerializerOptions()); + + Assert.AreEqual("{\"a\":1}", Encoding.UTF8.GetString(frame, probe.Result.Offset, probe.Result.Length)); + } + + [TestMethod] + public void TestUWritingASliceIsRejected() + { + SliceProbe probe = new SliceProbe { Result = new JsonSlice(0, 2) }; + + Assert.ThrowsExactly( + () => JsonSerializer.Serialize(probe, new JsonSerializerOptions())); + } + + /// + /// The production path runs on XrplJsonOptions.Default, which carries three dozen + /// converters; the bounds must not depend on the bare options used elsewhere here. + /// + [TestMethod] + public void TestUSliceIsTheSameUnderProductionOptions() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"id\":\"7\",\"result\":{\"a\":1},\"x\":2}"); + + SliceProbe probe = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); + + Assert.AreEqual("{\"a\":1}", Encoding.UTF8.GetString(frame, probe.Result.Offset, probe.Result.Length)); + } + + /// + /// A frame large enough that a chunked reader would drift. Small fixtures happen to give + /// the right offsets even off a stream, so only a big one pins the contract. + /// + [TestMethod] + public void TestUSliceStaysExactOnALargeFrame() + { + StringBuilder builder = new StringBuilder(48 * 1024); + builder.Append("{\"pad\":\"").Append('x', 40 * 1024).Append("\",\"result\":{\"a\":1}}"); + byte[] frame = Encoding.UTF8.GetBytes(builder.ToString()); + + SliceProbe probe = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); + + Assert.AreEqual("{\"a\":1}", Encoding.UTF8.GetString(frame, probe.Result.Offset, probe.Result.Length)); + } + /// + /// spans the one value a frame holds, and refuses a buffer + /// holding more than one. + /// + /// + /// Returning the first value's bounds for a two-value buffer would describe part of the input + /// as the whole of it. Unreachable through the pipeline - the deserializer rejects such a + /// frame before AttachFrame runs - but the method is the definition of "the frame's + /// bounds", so it should not quietly answer for half of one. + /// + [TestMethod] + public void TestUJsonSliceOfDocumentRejectsASecondTopLevelValue() + { + Assert.Throws(() => JsonSlice.OfDocument(Encoding.UTF8.GetBytes("{} {}"))); + Assert.Throws(() => JsonSlice.OfDocument(Encoding.UTF8.GetBytes("{}[]"))); + + // Trailing whitespace is not a second value. + JsonSlice padded = JsonSlice.OfDocument(Encoding.UTF8.GetBytes("{\"a\":1} ")); + Assert.AreEqual(0, padded.Offset); + Assert.AreEqual(7, padded.Length); + } +} diff --git a/Tests/Xrpl.Tests/Client/Json/Converters/LedgerBinaryConverterTests.cs b/Tests/Xrpl.Tests/Client/Json/Converters/LedgerBinaryConverterTests.cs index feebd247..dee1d69f 100644 --- a/Tests/Xrpl.Tests/Client/Json/Converters/LedgerBinaryConverterTests.cs +++ b/Tests/Xrpl.Tests/Client/Json/Converters/LedgerBinaryConverterTests.cs @@ -30,7 +30,7 @@ public void Read_WithLedgerData_ReturnsLedgerBinaryEntity() Assert.IsInstanceOfType(result.Ledger, typeof(LedgerBinaryEntity)); LedgerBinaryEntity binary = (LedgerBinaryEntity)result.Ledger; Assert.AreEqual("ABCD1234", binary.LedgerData); - Assert.IsTrue(binary.Closed); + Assert.AreEqual(true, binary.Closed); } [TestMethod] @@ -42,6 +42,6 @@ public void Read_WithoutLedgerData_ReturnsLedgerEntity() Model result = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); Assert.IsNotNull(result.Ledger); Assert.IsInstanceOfType(result.Ledger, typeof(LedgerEntity)); - Assert.IsFalse(result.Ledger.Closed); + Assert.AreEqual(false, result.Ledger.Closed); } } diff --git a/Tests/Xrpl.Tests/Client/Json/Converters/LedgerEntryExtensionDataTests.cs b/Tests/Xrpl.Tests/Client/Json/Converters/LedgerEntryExtensionDataTests.cs new file mode 100644 index 00000000..c9384af0 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/Json/Converters/LedgerEntryExtensionDataTests.cs @@ -0,0 +1,220 @@ +using System.Text.Json; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Client.Json; +using Xrpl.Models; +using Xrpl.Models.Ledger; +using Xrpl.Models.Transactions; + +namespace XrplTests.Client.Json.Converters; + +// Covers BaseLedgerEntry.UnknownFields: an amendment (or any node) can add a field to a ledger +// entry before this SDK models it. Before this attribute, that field was silently dropped on +// deserialize with no error and no trace of it in the typed model. +[TestClass] +public class TestULedgerEntryExtensionData +{ + private static readonly JsonSerializerOptions Options = XrplJsonOptions.Default; + + private const string AccountRootJsonWithUnknownField = @"{ + ""LedgerEntryType"": ""AccountRoot"", + ""Account"": ""rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"", + ""Balance"": ""10000000000"", + ""Flags"": 0, + ""Sequence"": 1, + ""NewAmendmentField"": ""probe-value"" + }"; + + [TestMethod] + public void Deserialize_LOAccountRoot_Direct_CapturesUnknownField() + { + LOAccountRoot result = JsonSerializer.Deserialize(AccountRootJsonWithUnknownField, Options); + + Assert.IsNotNull(result.UnknownFields); + Assert.IsTrue(result.UnknownFields.ContainsKey("NewAmendmentField")); + Assert.AreEqual("probe-value", result.UnknownFields["NewAmendmentField"].GetString()); + } + + [TestMethod] + public void Deserialize_LOAccountRoot_ThroughLOConverter_CapturesUnknownField() + { + // Goes through LOConverter.Read -> GetTypeForLedgerEntry -> JsonSerializer.Deserialize for + // the concrete LOAccountRoot type. LOConverter itself is stripped from the inner options to + // avoid recursion, but that only removes the envelope-level dispatch; the field-level read + // for LOAccountRoot is still the ordinary reflection-based deserializer. + BaseLedgerEntry result = JsonSerializer.Deserialize(AccountRootJsonWithUnknownField, Options); + + Assert.IsInstanceOfType(result, typeof(LOAccountRoot)); + LOAccountRoot accountRoot = (LOAccountRoot)result; + Assert.IsNotNull(accountRoot.UnknownFields); + Assert.IsTrue(accountRoot.UnknownFields.ContainsKey("NewAmendmentField")); + } + + [TestMethod] + public void Deserialize_ModifiedNode_FinalFieldsAndPreviousFields_CaptureUnknownFields() + { + string json = @"{ + ""LedgerEntryType"": ""AccountRoot"", + ""LedgerIndex"": ""ABCDEF"", + ""PreviousTxnID"": ""DEADBEEF"", + ""PreviousTxnLgrSeq"": 12345, + ""FinalFields"": { + ""Account"": ""rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"", + ""Balance"": ""10000000000"", + ""Flags"": 0, + ""Sequence"": 1, + ""NewAmendmentField"": ""final-value"" + }, + ""PreviousFields"": { + ""Balance"": ""9000000000"", + ""AnotherUnknownField"": 42 + } + }"; + + ModifiedNode node = JsonSerializer.Deserialize(json, Options); + + LOAccountRoot final = node.FinalFields as LOAccountRoot; + Assert.IsNotNull(final); + Assert.IsNotNull(final.UnknownFields); + Assert.IsTrue(final.UnknownFields.ContainsKey("NewAmendmentField")); + + LOAccountRoot previous = node.PreviousFields as LOAccountRoot; + Assert.IsNotNull(previous); + Assert.IsNotNull(previous.UnknownFields); + Assert.IsTrue(previous.UnknownFields.ContainsKey("AnotherUnknownField")); + Assert.AreEqual(42, previous.UnknownFields["AnotherUnknownField"].GetInt32()); + } + + [TestMethod] + public void Deserialize_LOAccountRoot_WithOnlyKnownFields_LeavesUnknownFieldsEmpty() + { + string json = @"{ + ""LedgerEntryType"": ""AccountRoot"", + ""Account"": ""rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"", + ""Balance"": ""10000000000"", + ""Flags"": 0, + ""Sequence"": 1 + }"; + + LOAccountRoot result = JsonSerializer.Deserialize(json, Options); + + // System.Text.Json leaves the extension-data dictionary null (not an empty dictionary) when + // nothing overflowed into it. + Assert.IsTrue(result.UnknownFields == null || result.UnknownFields.Count == 0); + } + + [TestMethod] + public void Serialize_LOAccountRoot_Direct_RoundTripsUnknownField() + { + LOAccountRoot result = JsonSerializer.Deserialize(AccountRootJsonWithUnknownField, Options); + + string output = JsonSerializer.Serialize(result, Options); + + using JsonDocument doc = JsonDocument.Parse(output); + Assert.IsTrue(doc.RootElement.TryGetProperty("NewAmendmentField", out JsonElement value)); + Assert.AreEqual("probe-value", value.GetString()); + } + + [TestMethod] + public void Serialize_ModifiedNode_RoundTripsUnknownFieldInsideFinalFields() + { + string json = @"{ + ""LedgerEntryType"": ""AccountRoot"", + ""LedgerIndex"": ""ABCDEF"", + ""PreviousTxnID"": ""DEADBEEF"", + ""PreviousTxnLgrSeq"": 12345, + ""FinalFields"": { + ""Account"": ""rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"", + ""Balance"": ""10000000000"", + ""Flags"": 0, + ""Sequence"": 1, + ""NewAmendmentField"": ""final-value"" + } + }"; + + ModifiedNode node = JsonSerializer.Deserialize(json, Options); + string output = JsonSerializer.Serialize(node, Options); + + using JsonDocument doc = JsonDocument.Parse(output); + JsonElement finalFields = doc.RootElement.GetProperty("FinalFields"); + Assert.IsTrue(finalFields.TryGetProperty("NewAmendmentField", out JsonElement value)); + Assert.AreEqual("final-value", value.GetString()); + } + + // A ledger object type this SDK's LedgerEntryType enum does not know (e.g. added by an + // amendment ahead of this SDK's release) falls back to bare BaseLedgerEntry in + // LOConverter.GetTypeForLedgerEntry - previously that meant every field but the three + // BaseLedgerEntry declares (LedgerEntryType, Index, LedgerIndex) was lost. UnknownFields on + // BaseLedgerEntry itself should now catch them, same as it does for a known type's extra + // field; these tests are the guard so a future converter change cannot silently regress that. + private const string UnknownLedgerEntryTypeJson = @"{ + ""LedgerEntryType"": ""SomeFutureAmendmentObject"", + ""index"": ""ABCDEF0123456789"", + ""Owner"": ""rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"", + ""SomeNewAmount"": ""1000000"", + ""SomeNewFlag"": true + }"; + + [TestMethod] + public void Deserialize_UnknownLedgerEntryType_ThroughLOConverter_CapturesUnknownFields() + { + BaseLedgerEntry result = JsonSerializer.Deserialize(UnknownLedgerEntryTypeJson, Options); + + Assert.AreEqual(typeof(BaseLedgerEntry), result.GetType()); + Assert.AreEqual(LedgerEntryType.Unknown, result.LedgerEntryType); + Assert.AreEqual("ABCDEF0123456789", result.Index); + Assert.IsNotNull(result.UnknownFields); + Assert.IsTrue(result.UnknownFields.ContainsKey("Owner")); + Assert.IsTrue(result.UnknownFields.ContainsKey("SomeNewAmount")); + Assert.IsTrue(result.UnknownFields.ContainsKey("SomeNewFlag")); + Assert.AreEqual("1000000", result.UnknownFields["SomeNewAmount"].GetString()); + Assert.IsTrue(result.UnknownFields["SomeNewFlag"].GetBoolean()); + } + + [TestMethod] + public void Serialize_UnknownLedgerEntryType_RoundTripsUnknownFields() + { + BaseLedgerEntry result = JsonSerializer.Deserialize(UnknownLedgerEntryTypeJson, Options); + + string output = JsonSerializer.Serialize(result, Options); + + using JsonDocument doc = JsonDocument.Parse(output); + Assert.IsTrue(doc.RootElement.TryGetProperty("Owner", out JsonElement owner)); + Assert.AreEqual("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", owner.GetString()); + Assert.IsTrue(doc.RootElement.TryGetProperty("SomeNewAmount", out JsonElement amount)); + Assert.AreEqual("1000000", amount.GetString()); + Assert.IsTrue(doc.RootElement.TryGetProperty("SomeNewFlag", out JsonElement flag)); + Assert.IsTrue(flag.GetBoolean()); + } + + [TestMethod] + public void Deserialize_ModifiedNode_FinalFields_UnknownLedgerEntryType_CapturesUnknownFields() + { + string json = @"{ + ""LedgerEntryType"": ""SomeFutureAmendmentObject"", + ""LedgerIndex"": ""ABCDEF"", + ""PreviousTxnID"": ""DEADBEEF"", + ""PreviousTxnLgrSeq"": 12345, + ""FinalFields"": { + ""Owner"": ""rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"", + ""SomeNewAmount"": ""1000000"" + } + }"; + + ModifiedNode node = JsonSerializer.Deserialize(json, Options); + + Assert.AreEqual(typeof(BaseLedgerEntry), node.FinalFields.GetType()); + Assert.IsNotNull(node.FinalFields.UnknownFields); + Assert.IsTrue(node.FinalFields.UnknownFields.ContainsKey("Owner")); + Assert.IsTrue(node.FinalFields.UnknownFields.ContainsKey("SomeNewAmount")); + + string output = JsonSerializer.Serialize(node, Options); + using JsonDocument doc = JsonDocument.Parse(output); + JsonElement finalFields = doc.RootElement.GetProperty("FinalFields"); + Assert.IsTrue(finalFields.TryGetProperty("Owner", out JsonElement owner)); + Assert.AreEqual("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", owner.GetString()); + Assert.IsTrue(finalFields.TryGetProperty("SomeNewAmount", out JsonElement amount)); + Assert.AreEqual("1000000", amount.GetString()); + } +} diff --git a/Tests/Xrpl.Tests/Client/Json/Converters/TransactionResponseExtensionDataTests.cs b/Tests/Xrpl.Tests/Client/Json/Converters/TransactionResponseExtensionDataTests.cs new file mode 100644 index 00000000..68ee3ead --- /dev/null +++ b/Tests/Xrpl.Tests/Client/Json/Converters/TransactionResponseExtensionDataTests.cs @@ -0,0 +1,86 @@ +using System.Text.Json; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Client.Json; +using Xrpl.Models.Transactions; + +namespace XrplTests.Client.Json.Converters; + +// Covers BaseTransactionResponse.UnknownFields. Measured on live mainnet responses: rippled sends +// fields this SDK did not model at the time — before this attribute they were silently dropped on +// deserialize. "ctid" (compact transaction ID) was the original trigger for this coverage; it has +// since become a modeled property (BaseTransactionResponse.Ctid — see +// TestUBaseTransactionResponseFields), so the fixture below now exercises an unrelated field that +// stays genuinely unknown, so this file keeps covering what it was written to cover. +[TestClass] +public class TestUTransactionResponseExtensionData +{ + private static readonly JsonSerializerOptions Options = XrplJsonOptions.Default; + + private const string PaymentJsonWithUnknownField = @"{ + ""TransactionType"": ""Payment"", + ""Account"": ""rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"", + ""Destination"": ""rDestAccount1111111111111111111"", + ""Amount"": ""1000000"", + ""Fee"": ""10"", + ""Sequence"": 5, + ""hash"": ""ABCDEF0123456789"", + ""a_field_no_model_knows"": ""C000001200000000"" + }"; + + [TestMethod] + public void Deserialize_PaymentResponse_Direct_CapturesUnknownField() + { + PaymentResponse result = JsonSerializer.Deserialize(PaymentJsonWithUnknownField, Options); + + Assert.IsNotNull(result.UnknownFields); + Assert.IsTrue(result.UnknownFields.ContainsKey("a_field_no_model_knows")); + Assert.AreEqual("C000001200000000", result.UnknownFields["a_field_no_model_knows"].GetString()); + } + + [TestMethod] + public void Deserialize_ITransactionResponse_ThroughConverter_CapturesUnknownField() + { + // Goes through TransactionResponseConverter.Read -> Create(transactionType) -> the ordinary + // reflection-based deserializer for the concrete PaymentResponse type. The converter is + // stripped from the inner options only to avoid re-entering itself for the same interface; + // it never intercepts field-level reads on the concrete response type. + ITransactionResponse result = JsonSerializer.Deserialize(PaymentJsonWithUnknownField, Options); + + Assert.IsInstanceOfType(result, typeof(PaymentResponse)); + PaymentResponse payment = (PaymentResponse)result; + Assert.IsNotNull(payment.UnknownFields); + Assert.IsTrue(payment.UnknownFields.ContainsKey("a_field_no_model_knows")); + } + + [TestMethod] + public void Deserialize_PaymentResponse_WithOnlyKnownFields_LeavesUnknownFieldsEmpty() + { + string json = @"{ + ""TransactionType"": ""Payment"", + ""Account"": ""rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"", + ""Destination"": ""rDestAccount1111111111111111111"", + ""Amount"": ""1000000"", + ""Fee"": ""10"", + ""Sequence"": 5, + ""hash"": ""ABCDEF0123456789"" + }"; + + PaymentResponse result = JsonSerializer.Deserialize(json, Options); + + Assert.IsTrue(result.UnknownFields == null || result.UnknownFields.Count == 0); + } + + [TestMethod] + public void Serialize_ITransactionResponse_RoundTripsUnknownField() + { + ITransactionResponse result = JsonSerializer.Deserialize(PaymentJsonWithUnknownField, Options); + + string output = JsonSerializer.Serialize(result, Options); + + using JsonDocument doc = JsonDocument.Parse(output); + Assert.IsTrue(doc.RootElement.TryGetProperty("a_field_no_model_knows", out JsonElement value)); + Assert.AreEqual("C000001200000000", value.GetString()); + } +} diff --git a/Tests/Xrpl.Tests/Client/Json/RawJsonTests.cs b/Tests/Xrpl.Tests/Client/Json/RawJsonTests.cs new file mode 100644 index 00000000..82586d34 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/Json/RawJsonTests.cs @@ -0,0 +1,357 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Buffers; +using System.Text; +using System.Text.Json; + +using Xrpl.Client.Json; +using Xrpl.Models.Ledger; + +namespace XrplTests.Client.Json; + +/// +/// Pins the contract of the window a consumer is handed onto the bytes a node actually sent: +/// it aliases the frame rather than copying it, detaches only through , +/// rejects a window that does not lie inside its frame, and round-trips through a +/// byte-for-byte — including the zero-length window an absent +/// response member produces. +/// +[TestClass] +public class TestURawJson +{ + [TestMethod] + public void TestURawJsonRendersTheOriginalBytes() + { + // `{"result": {"a" : 1} }` — the inner object starts at byte 11 and is 9 bytes long. + byte[] frame = Encoding.UTF8.GetBytes("{\"result\": {\"a\" : 1} }"); + RawJson raw = new RawJson(frame, 11, 9); + + Assert.AreEqual("{\"a\" : 1}", raw.ToString()); + Assert.AreEqual(9, raw.Length); + Assert.IsFalse(raw.IsEmpty); + } + + [TestMethod] + public void TestURawJsonDefaultIsEmpty() + { + RawJson raw = default; + + Assert.IsTrue(raw.IsEmpty); + Assert.AreEqual(string.Empty, raw.ToString()); + Assert.AreEqual(0, raw.Span.Length); + Assert.AreEqual(0, raw.Length); + Assert.AreEqual(0, default(RawJson).ToArray().Length); + } + + [TestMethod] + public void TestURawJsonSpanAliasesTheFrame() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"result\":{\"a\":1}}"); + RawJson raw = new RawJson(frame, 10, 7); + + frame[12] = (byte)'b'; + + // Not a copy: the window addresses the frame's own bytes, so the frame's mutation shows. + Assert.AreEqual("{\"b\":1}", raw.ToString()); + } + + [TestMethod] + public void TestURawJsonToArrayDetachesFromTheFrame() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"result\":{\"a\":1}}"); + byte[] copy = new RawJson(frame, 10, 7).ToArray(); + + frame[12] = (byte)'b'; + + CollectionAssert.AreEqual(Encoding.UTF8.GetBytes("{\"a\":1}"), copy); + } + + /// The shape an absent member produces: a live frame with a zero-length window. + [TestMethod] + public void TestURawJsonZeroLengthWindowIsEmpty() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"id\":\"7\"}"); + RawJson raw = new RawJson(frame, 0, 0); + + Assert.IsTrue(raw.IsEmpty); + Assert.AreEqual(0, raw.Length); + Assert.AreEqual(string.Empty, raw.ToString()); + } + + [TestMethod] + public void TestURawJsonWriteToEmitsTheBytesVerbatim() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"result\": {\"a\" : 1,\"b\":[2, 3]} }"); + RawJson raw = new RawJson(frame, 11, 20); + + ArrayBufferWriter buffer = new ArrayBufferWriter(); + using (Utf8JsonWriter writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartObject(); + writer.WritePropertyName("result"); + raw.WriteTo(writer); + writer.WriteEndObject(); + } + + Assert.AreEqual("{\"result\":{\"a\" : 1,\"b\":[2, 3]}}", Encoding.UTF8.GetString(buffer.WrittenSpan)); + } + + /// Regression: a zero-length window used to reach WriteRawValue and throw. + [TestMethod] + public void TestURawJsonWriteToEmitsNullForAnEmptyWindow() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"id\":\"7\"}"); + + Assert.AreEqual("null", Write(new RawJson(frame, 0, 0))); + Assert.AreEqual("null", Write(default)); + + static string Write(RawJson raw) + { + ArrayBufferWriter buffer = new ArrayBufferWriter(); + using (Utf8JsonWriter writer = new Utf8JsonWriter(buffer)) + { + raw.WriteTo(writer); + } + + return Encoding.UTF8.GetString(buffer.WrittenSpan); + } + } + + [TestMethod] + public void TestURawJsonWriteToRejectsANullWriter() + { + // Constructed on its own line so the assert below targets only WriteTo: a (null, 0, 0) + // window is the valid empty-frame shape and does not throw, but folding construction into + // the assert expression would let a future constructor regression pass this test for the + // wrong reason. + RawJson raw = new RawJson(null, 0, 0); + + Assert.ThrowsExactly(() => raw.WriteTo(null)); + } + + [TestMethod] + public void TestURawJsonRejectsAWindowOutsideTheFrame() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"a\":1}"); + + Assert.ThrowsExactly(() => new RawJson(frame, 3, 99)); + Assert.ThrowsExactly(() => new RawJson(frame, -1, 2)); + Assert.ThrowsExactly(() => new RawJson(frame, 0, -2)); + } + + /// Length is bytes, not characters. + [TestMethod] + public void TestURawJsonLengthIsInBytes() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"v\":\"é中😀\"}"); + RawJson raw = new RawJson(frame, 5, frame.Length - 6); + + Assert.AreEqual("\"é中😀\"", raw.ToString()); + Assert.AreEqual(frame.Length - 6, raw.Length); + } + + /// + /// Equality is identity of the window, not of the bytes: same frame, same bounds. Comparing + /// content is what Span is for. Pinned here because this is a public contract — changing it + /// later is a breaking change, and an untested contract drifts just as quietly as an unstated one. + /// + [TestMethod] + public void TestURawJsonEqualityIsIdentityOfTheWindow() + { + // Two windows onto one frame, each a complete JSON value: the constructor validates that + // now, so the arbitrary byte ranges this test used before ("{\"a" and the like) no longer + // construct. Identity is still what is under test - same bytes, different windows. + byte[] frame = Encoding.UTF8.GetBytes("[1,2]"); + byte[] twin = Encoding.UTF8.GetBytes("[1,2]"); + + Assert.IsTrue(new RawJson(frame, 1, 1) == new RawJson(frame, 1, 1)); + Assert.IsTrue(new RawJson(frame, 1, 1) != new RawJson(frame, 3, 1)); + Assert.IsTrue(new RawJson(frame, 1, 1) != new RawJson(twin, 1, 1)); + Assert.IsTrue(default(RawJson) == default(RawJson)); + Assert.IsFalse(new RawJson(frame, 1, 1).Equals("not a RawJson")); + + // The bounds have to reach the hash: the default struct hash used the frame reference alone, + // so two different windows onto one frame collided. + Assert.AreNotEqual(new RawJson(frame, 1, 1).GetHashCode(), new RawJson(frame, 3, 1).GetHashCode()); + } + + /// + /// The payload is deliberately awkward: the key differs in case and the number arrives as a + /// string. Both are read only because XrplJsonOptions.Default sets PropertyNameCaseInsensitive + /// and AllowReadingFromString — under bare options this deserializes to zero, which is what + /// makes the test able to tell the two apart. + /// + [TestMethod] + public void TestURawJsonDeserializesWithLibraryOptions() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"result\":{\"Ledger_Index\":\"9\",\"marker\":\"AABB\"}}"); + RawJson raw = new RawJson(frame, 10, frame.Length - 11); + + LOLedgerData typed = raw.Deserialize(); + + Assert.IsNotNull(typed); + Assert.AreEqual(9u, typed.LedgerIndex); + Assert.AreEqual("AABB", typed.Marker.ToString()); + } + + [TestMethod] + public void TestURawJsonDeserializeOnAnEmptyWindowReturnsDefault() + { + Assert.IsNull(default(RawJson).Deserialize()); + } + + [TestMethod] + public void TestURawJsonToJsonElementOwnsItsData() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"result\":{\"a\":1}}"); + JsonElement element = new RawJson(frame, 10, 7).ToJsonElement(); + + // Wipe the whole window the element was parsed from, not just one byte: if the element + // aliased the frame instead of copying out of it, this would corrupt every field it reads. + Array.Clear(frame, 10, 7); + + Assert.AreEqual(1, element.GetProperty("a").GetInt32()); + } + + [TestMethod] + public void TestURawJsonToJsonElementOnAnEmptyWindowIsUndefined() + { + Assert.AreEqual(JsonValueKind.Undefined, default(RawJson).ToJsonElement().ValueKind); + } + + /// The property is found whether it comes first or after another top-level member. + [TestMethod] + public void TestURawJsonFindsAPropertyAtTheTopLevel() + { + Assert.IsTrue(Window("{\"marker\":1,\"a\":2}").HasTopLevelProperty("marker"u8)); + + // Reaching it means the preceding member, itself an object holding an array, was skipped + // whole rather than walked into. + Assert.IsTrue(Window("{\"a\":{\"b\":[1,2]},\"marker\":1}").HasTopLevelProperty("marker"u8)); + } + + /// A property of the same name nested inside another member is not the top-level one. + [TestMethod] + public void TestURawJsonDoesNotMistakeANestedOccurrenceForTopLevel() + { + Assert.IsFalse(Window("{\"a\":[{\"marker\":1}]}").HasTopLevelProperty("marker"u8)); + } + + /// + /// Matching follows the serializer, which reads the same document with + /// PropertyNameCaseInsensitive = true — a key that fills a typed property must not read + /// as absent here. Was untested: the only case-insensitivity test in this branch went through + /// JsonSlice, so reverting this method alone to an ordinal comparison broke nothing. + /// + [TestMethod] + public void TestURawJsonMatchesTheTopLevelPropertyRegardlessOfCase() + { + Assert.IsTrue(Window("{\"MARKER\":1}").HasTopLevelProperty("marker"u8)); + Assert.IsTrue(Window("{\"Marker\":1}").HasTopLevelProperty("marker"u8)); + Assert.IsFalse(Window("{\"marker_extra\":1}").HasTopLevelProperty("marker"u8)); + } + + /// + /// Case folding applies to letters only. + /// + /// + /// The pairs below differ from the target by exactly 0x20 — the bit that folds a letter's + /// case — while being non-letters: { (0x7B) against [ (0x5B), and DEL (0x7F) + /// against _ (0x5F). A comparison that flipped that bit unconditionally would match + /// them, so these are what actually pin the guard. + /// + /// An earlier version of this test used _ against ?, which differ by 0x60, not + /// 0x20 — no fold could ever have confused them, so removing the guard entirely left the test + /// green. Verified the current pairs by mutation: they turn red. + /// + [TestMethod] + public void TestURawJsonFoldsLettersOnlyWhenMatchingAPropertyName() + { + Assert.IsFalse(Window("{\"tx{json\":1}").HasTopLevelProperty("tx[json"u8)); + Assert.IsFalse(Window("{\"txjson\":1}").HasTopLevelProperty("tx_json"u8)); + Assert.IsTrue(Window("{\"TX_JSON\":1}").HasTopLevelProperty("tx_json"u8)); + } + + /// + /// The public constructor rejects a window that is not exactly one JSON value. + /// + /// + /// writes the window through without validating - that is the + /// point of the type - so a partial or malformed window would be spliced verbatim into the + /// document being written and corrupt it with no exception anywhere. Checking once at + /// construction costs nothing per write; the internal Trusted path skips it for bounds + /// the SDK already produced through Utf8JsonReader.Skip(). + /// + [TestMethod] + public void TestURawJsonRejectsAWindowThatIsNotOneCompleteValue() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"a\":1} {\"b\":2}"); + + // Throws, not ThrowsExactly: a truncated or malformed window surfaces as + // JsonReaderException, which derives from JsonException, while the "two values" case is + // raised as JsonException directly. The contract is the base type. + // + // Partial: the window stops inside the object. + Assert.Throws(() => new RawJson(frame, 0, 4)); + + // Two values: legal JSON individually, not one value together. + Assert.Throws(() => new RawJson(frame, 0, frame.Length)); + + // Malformed outright. + byte[] broken = Encoding.UTF8.GetBytes("{\"a\":}"); + Assert.Throws(() => new RawJson(broken, 0, broken.Length)); + + // One complete value, with surrounding whitespace, is fine. + byte[] padded = Encoding.UTF8.GetBytes(" {\"a\":1} "); + Assert.AreEqual(padded.Length, new RawJson(padded, 0, padded.Length).Length); + } + + /// + /// The frame is aliased, not copied: mutating it afterwards changes what the window reads. + /// + /// + /// No check can prevent this - validation happens once, at construction - so it is pinned as + /// documented behaviour rather than left for someone to discover. + /// is the way out when the buffer is not the caller's alone. + /// + [TestMethod] + public void TestURawJsonAliasesTheFrameRatherThanCopyingIt() + { + byte[] frame = Encoding.UTF8.GetBytes("[1,2]"); + RawJson window = new RawJson(frame, 1, 1); + byte[] detached = window.ToArray(); + + Assert.AreEqual("1", window.ToString()); + + frame[1] = (byte)'9'; + + Assert.AreEqual("9", window.ToString(), "the window reads through to the frame - mutating it changes what the window sees"); + Assert.AreEqual("1", Encoding.UTF8.GetString(detached), "ToArray detaches, which is the documented way to keep the bytes"); + } + + /// An empty object, a non-object document, and an empty window all answer false. + [TestMethod] + public void TestURawJsonHasNoTopLevelPropertyOnANonObjectOrEmptyInput() + { + Assert.IsFalse(Window("{}").HasTopLevelProperty("marker"u8)); + Assert.IsFalse(Window("[1,2]").HasTopLevelProperty("marker"u8)); + Assert.IsFalse(default(RawJson).HasTopLevelProperty("marker"u8)); + } + + /// + /// Works only because the scan goes through ValueTextEquals, which unescapes. Swapping it for + /// a raw byte comparison would pass every other case here and break this one silently. + /// + [TestMethod] + public void TestURawJsonMatchesAnEscapedTopLevelKey() + { + Assert.IsTrue(Window("{\"\\u006darker\":1}").HasTopLevelProperty("marker"u8)); + } + + private static RawJson Window(string json) + { + byte[] frame = Encoding.UTF8.GetBytes(json); + return new RawJson(frame, 0, frame.Length); + } +} diff --git a/Tests/Xrpl.Tests/Client/Json/TestUInterfaceSerialization.cs b/Tests/Xrpl.Tests/Client/Json/TestUInterfaceSerialization.cs new file mode 100644 index 00000000..9fd362b6 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/Json/TestUInterfaceSerialization.cs @@ -0,0 +1,162 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System.Text.Json; + +using Xrpl.Client.Json; +using Xrpl.Models.Common; +using Xrpl.Models.Transactions; + +namespace Xrpl.Tests.Client.Json +{ + /// + /// A transaction held in a variable typed as its interface serializes like the transaction it + /// is - issue #127. + /// + /// + /// + /// System.Text.Json picks a converter by the declared type. The attribute sat on the + /// abstract class, so a variable declared as ITransactionRequest did not get it and was + /// written as the interface: every field of the actual transaction type gone, the payment left + /// with no Amount and no Destination, and no exception or warning anywhere. + /// + /// + /// Interfaces are what one declares in the places where the transaction type is the caller's + /// choice - factories, submission pipelines, wallet wrappers. It happened to work as long as + /// everything went through XrplJsonOptions.Default, whose converter list makes up for + /// the missing attribute, and stopped the moment someone serialized with their own options. + /// + /// + [TestClass] + public class TestUInterfaceSerialization + { + private static Payment APayment() => new Payment + { + Account = "r4f4xLpXJtCh9PwdzsQ6KYwLevVnBpJV6f", + Destination = "rQUSmV11JUe71qEJNsTQcw4rqzYDyEHZEG", + Amount = new Currency { ValueAsXrp = 5 }, + }; + + /// + /// The same transaction, declared two ways, must serialize to the same JSON - with the + /// caller's own options, which is where this used to diverge. + /// + [TestMethod] + public void TestUInterfaceAndConcreteTypeSerializeAlike() + { + Payment payment = APayment(); + ITransactionRequest asInterface = payment; + + string viaInterface = JsonSerializer.Serialize(asInterface); + string viaConcrete = JsonSerializer.Serialize(payment); + + Assert.AreEqual( + viaConcrete, + viaInterface, + "A variable's declared type must not decide what reaches the wire."); + } + + /// + /// Named individually, because "the same JSON" says nothing about whether either is right. + /// + [TestMethod] + public void TestUInterfaceKeepsTheFieldsOfTheActualTransaction() + { + ITransactionRequest asInterface = APayment(); + + string json = JsonSerializer.Serialize(asInterface); + + StringAssert.Contains(json, "Destination", $"A payment without a destination is not a payment: {json}"); + StringAssert.Contains(json, "Amount", $"A payment without an amount is not a payment: {json}"); + } + + /// + /// And the transaction type reaches the wire as a name, not as the number behind the enum. + /// + /// + /// The converter that spells it out sits on the class property too, so it went missing by + /// the same route: "TransactionType":16 instead of "Payment". + /// + [TestMethod] + public void TestUInterfaceWritesTheTransactionTypeAsAName() + { + ITransactionRequest asInterface = APayment(); + + string json = JsonSerializer.Serialize(asInterface); + + // The pair, not the two halves separately: asserting only that "Payment" appears + // somewhere would also pass if the field held the enum's number and the name turned up + // in an unrelated place, and asserting the absence of ":16" would stop testing anything + // the day that number changes. + StringAssert.Contains( + json, + "\"TransactionType\":\"Payment\"", + $"a node reads the name, not the number behind the enum: {json}"); + } + + /// + /// The signature fields keep the names the protocol uses. + /// + /// + /// The class declares [JsonPropertyName("SigningPubKey")] SigningPublicKey; the + /// interface declares the property without the attribute. Serialized as the interface, the + /// transaction carried SigningPublicKey and TransactionSignature - names no + /// node knows. + /// + [TestMethod] + public void TestUInterfaceWritesTheProtocolNamesForSignatureFields() + { + Payment payment = APayment(); + payment.SigningPublicKey = "ED9434799226374926EDA3B54B1B461B4ABF7237962EAE18528FEA67595397FA32"; + payment.TransactionSignature = "12345678"; + ITransactionRequest asInterface = payment; + + string json = JsonSerializer.Serialize(asInterface); + + StringAssert.Contains(json, "SigningPubKey", $"the protocol's name for the key: {json}"); + StringAssert.Contains(json, "TxnSignature", $"the protocol's name for the signature: {json}"); + Assert.IsFalse(json.Contains("SigningPublicKey"), $"the C# name must not reach the wire: {json}"); + Assert.IsFalse(json.Contains("TransactionSignature"), $"the C# name must not reach the wire: {json}"); + } + + /// + /// Reading is the other half of the same attribute, and it works through the interface too. + /// + /// + /// Before, deserializing into a variable of this type with anything but the SDK's options + /// had no converter to reach for and no way to build an interface. The same declaration + /// that fixes writing is what makes this possible, so it is tested rather than assumed. + /// + [TestMethod] + public void TestUAnInterfaceVariableRoundTrips() + { + string json = JsonSerializer.Serialize(APayment()); + + ITransactionRequest restored = JsonSerializer.Deserialize(json); + + Assert.IsInstanceOfType( + restored, + "The discriminator in the JSON is what decides the type, and it says Payment."); + + Payment payment = (Payment)restored; + Assert.AreEqual("rQUSmV11JUe71qEJNsTQcw4rqzYDyEHZEG", payment.Destination); + Assert.IsNotNull(payment.Amount, "An amount that survived the trip out must survive the trip back."); + } + + /// + /// The SDK's own options were never the problem, and must stay unaffected. + /// + [TestMethod] + public void TestUTheSdkOptionsStillSerializeTheSameWay() + { + Payment payment = APayment(); + ITransactionRequest asInterface = payment; + + string viaInterface = JsonSerializer.Serialize(asInterface, XrplJsonOptions.Default); + string viaConcrete = JsonSerializer.Serialize(payment, XrplJsonOptions.Default); + + Assert.AreEqual(viaConcrete, viaInterface); + StringAssert.Contains(viaInterface, "\"Payment\""); + StringAssert.Contains(viaInterface, "Destination"); + } + } +} diff --git a/Tests/Xrpl.Tests/Client/ScriptedResponseServer.cs b/Tests/Xrpl.Tests/Client/ScriptedResponseServer.cs new file mode 100644 index 00000000..da9c237b --- /dev/null +++ b/Tests/Xrpl.Tests/Client/ScriptedResponseServer.cs @@ -0,0 +1,55 @@ +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Xrpl.Tests +{ + /// + /// WebSocket server that answers every request with a response body the test wrote itself, + /// byte for byte. + /// + /// + /// The other mock servers here either record requests or generate filler payloads. Neither can + /// show what this one is for: that the bytes a node sent survive the trip to the caller + /// unchanged — including whitespace the client never normalizes and members no model knows. + /// Only the id is substituted, because the client matches responses on it. + /// + internal sealed class ScriptedResponseServer : WebSocketTestServerBase + { + private readonly string _envelope; + + /// + /// The full response, with __ID__ where the request's id has to be echoed back. + /// + public ScriptedResponseServer(string envelope) + { + _envelope = envelope; + StartAccepting(); + } + + protected override async Task ServeAsync(NetworkStream stream) + { + while (!Token.IsCancellationRequested) + { + string request = await ReadTextFrameAsync(stream).ConfigureAwait(false); + if (request == null) + { + return; + } + + byte[] response = Encoding.UTF8.GetBytes(_envelope.Replace("__ID__", ExtractId(request))); + 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); + return document.RootElement.TryGetProperty("id", out JsonElement id) + ? id.GetRawText() + : "null"; + } + } +} diff --git a/Tests/Xrpl.Tests/Client/SilentOnPingServer.cs b/Tests/Xrpl.Tests/Client/SilentOnPingServer.cs new file mode 100644 index 00000000..35cdb411 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/SilentOnPingServer.cs @@ -0,0 +1,60 @@ +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Xrpl.Tests +{ + /// + /// WebSocket server that never answers a ping, and answers everything else with the + /// same server_info body. + /// + /// + /// The health check treats a connection with no inbound traffic past + /// InactivityTimeout as dead and hands it to the fast-reconnect path. Reaching that from + /// a test needs a peer that stays connected and stays quiet, which the shared mock cannot be: + /// it answers ping itself, and its pong refreshes the activity clock on every check, so + /// the timeout never comes however low it is set. Answering everything else is what keeps the + /// connection up long enough for the silence to matter. + /// + internal sealed class SilentOnPingServer : WebSocketTestServerBase + { + private const string ServerInfoEnvelope = + "{\"id\":__ID__,\"status\":\"success\",\"type\":\"response\",\"result\":{\"info\":" + + "{\"build_version\":\"test-mock\",\"complete_ledgers\":\"1-1\",\"server_state\":\"full\"}}}"; + + public SilentOnPingServer() + { + StartAccepting(); + } + + protected override async Task ServeAsync(NetworkStream stream) + { + while (!Token.IsCancellationRequested) + { + string request = await ReadTextFrameAsync(stream).ConfigureAwait(false); + if (request == null) + { + return; + } + + using JsonDocument document = JsonDocument.Parse(request); + string command = document.RootElement.TryGetProperty("command", out JsonElement value) + ? value.GetString() + : null; + + if (command == "ping") + { + continue; + } + + string id = document.RootElement.TryGetProperty("id", out JsonElement requestId) + ? requestId.GetRawText() + : "null"; + + byte[] response = Encoding.UTF8.GetBytes(ServerInfoEnvelope.Replace("__ID__", id)); + await WriteFragmentedMessageAsync(stream, response, fragments: 1).ConfigureAwait(false); + } + } + } +} diff --git a/Tests/Xrpl.Tests/Client/TestAutofill.cs b/Tests/Xrpl.Tests/Client/TestAutofill.cs index 8c5b770b..0fb508b0 100644 --- a/Tests/Xrpl.Tests/Client/TestAutofill.cs +++ b/Tests/Xrpl.Tests/Client/TestAutofill.cs @@ -104,7 +104,7 @@ public async Task TestAutofillSequence() [TestMethod] //[ExpectedException(typeof(NotConnectedException))] - public void TestAutofillDeteteBlockers() + public async Task TestAutofillDeteteBlockers() { Dictionary tx = new Dictionary { @@ -133,7 +133,7 @@ public void TestAutofillDeteteBlockers() runner.mockedRippled.AddResponse("server_info", serverInfoData); runner.mockedRippled.AddResponse("account_objects", accountObjectsData); - Dictionary txResult = runner.client.Autofill(tx).Result; + Dictionary txResult = await runner.client.Autofill(tx); } [TestMethod] diff --git a/Tests/Xrpl.Tests/Client/TestCancellationToken.cs b/Tests/Xrpl.Tests/Client/TestCancellationToken.cs index 61154d6e..a9356475 100644 --- a/Tests/Xrpl.Tests/Client/TestCancellationToken.cs +++ b/Tests/Xrpl.Tests/Client/TestCancellationToken.cs @@ -86,7 +86,7 @@ public async Task Resolve_BeforeCancel_ReturnsResult() string json = $"{{\"id\":\"{xrplRequest.Id}\",\"status\":\"success\",\"type\":\"response\",\"result\":{{\"value\":42}}}}"; rm.HandleResponse(json); - Dictionary result = await xrplRequest.Promise; + Dictionary result = XrplResponse.From>(await xrplRequest.Promise).Result; Assert.IsNotNull(result); cts.Cancel(); @@ -266,7 +266,7 @@ public async Task NextRequest_AfterCancel_WorksNormally() { ["command"] = "server_info" }; - Dictionary result = await runner.client.connection.Request(normalRequest); + Dictionary result = await runner.client.connection.Request(normalRequest).Typed(); Assert.IsNotNull(result, "Request after cancelled request should succeed"); } finally diff --git a/Tests/Xrpl.Tests/Client/TestUChangeServerFailure.cs b/Tests/Xrpl.Tests/Client/TestUChangeServerFailure.cs index 916ea34c..8bd35caf 100644 --- a/Tests/Xrpl.Tests/Client/TestUChangeServerFailure.cs +++ b/Tests/Xrpl.Tests/Client/TestUChangeServerFailure.cs @@ -134,7 +134,7 @@ public async Task TestChangeServerToUnreachableServerRecoversWhenItComesUp() Assert.AreEqual($"ws://127.0.0.1:{secondPort}", _client.connection.GetUrl()); Dictionary response = - await _client.Request(new Dictionary { { "command", "server_info" } }); + await _client.Request(new Dictionary { { "command", "server_info" } }).Typed(); Assert.IsNotNull(response, "Client must be usable on the new server."); } diff --git a/Tests/Xrpl.Tests/Client/TestUClientStreamEvents.cs b/Tests/Xrpl.Tests/Client/TestUClientStreamEvents.cs new file mode 100644 index 00000000..302ce755 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestUClientStreamEvents.cs @@ -0,0 +1,200 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Threading; +using System.Threading.Tasks; + +using Xrpl.Client; +using Xrpl.Models.Subscriptions; + +namespace XrplTests.Client; + +/// +/// Stream events are reachable through , not only through the concrete +/// connection field. +/// +/// +/// This is what makes the raw bytes on a stream event usable through the SDK's own contract: a +/// wallet renders transaction events for signing, and until now the only way to receive one +/// was client.connection.OnTransaction - a property of a concrete class, so code written +/// against the interface could neither subscribe nor be tested against a substitute client. +/// +[TestClass] +public class TestUClientStreamEvents +{ + private const string TransactionMessage = """ + { + "type": "transaction", + "status": "closed", + "validated": true, + "engine_result": "tesSUCCESS", + "tx_json": { + "TransactionType": "Payment", + "Account": "rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd", + "Sequence": 13 + }, + "meta": { "AffectedNodes": [], "TransactionIndex": 0, "TransactionResult": "tesSUCCESS" } + } + """; + + /// + /// Waits until the counter reaches , or fails. + /// + /// + /// OnMessage returning does not mean handlers have run: a stream frame is handed to the + /// background processor, and when there is none - an unconnected client, as here - to a task + /// that yields before doing anything, so that parsing and handler code never run on the + /// receive loop. Both paths are asynchronous, which is the point; the test has to wait rather + /// than assume. + /// + private static void WaitForCount(Func counter, int expected, string message) + { + DateTime deadline = DateTime.UtcNow.AddSeconds(5); + while (counter() < expected && DateTime.UtcNow < deadline) + { + Thread.Sleep(10); + } + + Assert.AreEqual(expected, counter(), message); + } + + /// + /// A handler registered through the interface receives the event, and the raw bytes come with + /// it - the whole point of reaching the stream through the contract. + /// + [TestMethod] + public async Task TestUSubscribingThroughTheInterfaceReceivesStreamEvents() + { + using XrplClient client = new XrplClient("wss://localhost:1/"); + IXrplClient contract = client; + + TaskCompletionSource received = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + contract.OnTransaction += r => + { + received.TrySetResult(r); + return Task.CompletedTask; + }; + + await client.connection.OnMessage(TransactionMessage); + + Task completed = await Task.WhenAny(received.Task, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.AreSame(received.Task, completed, "the handler registered through IXrplClient was never invoked"); + + TransactionStream result = await received.Task; + Assert.AreEqual(13u, result.Transaction.Sequence); + Assert.IsFalse(result.Raw.IsEmpty, "the event carries the bytes the node sent, which is why reaching it through the contract matters"); + } + + /// + /// Subscribing through the client and through its connection reach one list, because the + /// client forwards rather than relaying. + /// + /// + /// Pins the forwarding shape itself: a relaying implementation would keep its own subscriber + /// list, and removing through one surface would leave the other still subscribed. + /// + [TestMethod] + public async Task TestUHandlerAddedThroughTheClientCanBeRemovedThroughTheConnection() + { + using XrplClient client = new XrplClient("wss://localhost:1/"); + IXrplClient contract = client; + + int calls = 0; + OnTransaction handler = _ => + { + calls++; + return Task.CompletedTask; + }; + + // Subscribed through the client, removed through the connection. Only forwarding makes + // that work: a relaying client would keep its own subscriber list, the removal would miss + // it, and the handler would keep firing. Removing through the same surface it was added + // to cannot tell the two apart - both pass - which is why the test crosses surfaces. + contract.OnTransaction += handler; + + // A witness that is never removed: it tells the test when a frame has been dispatched. + // Without it, "calls is still 1" after the removal would pass just as well for a frame + // that has not been processed yet. + // + // Registered after the handler, and that order is load-bearing: a multicast delegate + // invokes its subscribers in registration order, so a witness registered first would + // report the frame as dispatched while the handler had not yet run - and the sanity + // assertion below would flake rather than fail. + int dispatched = 0; + client.connection.OnTransaction += _ => + { + Interlocked.Increment(ref dispatched); + return Task.CompletedTask; + }; + + await client.connection.OnMessage(TransactionMessage); + WaitForCount(() => Volatile.Read(ref dispatched), 1, "the first frame was never dispatched"); + Assert.AreEqual(1, calls, "sanity: the handler is attached"); + + client.connection.OnTransaction -= handler; + await client.connection.OnMessage(TransactionMessage); + WaitForCount(() => Volatile.Read(ref dispatched), 2, "the second frame was never dispatched"); + + Assert.AreEqual(1, calls, "removing through the connection left the handler attached - the client is relaying into its own list rather than forwarding"); + } + + /// + /// The mirror case: added through the connection, removed through the client. + /// + /// + /// Needed as its own test because the one above never executes the client's remove + /// accessor - it removes through the connection. Verified by mutation: turning the client's + /// remove into connection.OnTransaction += value left the whole suite green + /// until this existed. + /// + [TestMethod] + public async Task TestUHandlerAddedThroughTheConnectionCanBeRemovedThroughTheClient() + { + using XrplClient client = new XrplClient("wss://localhost:1/"); + IXrplClient contract = client; + + int calls = 0; + OnTransaction handler = _ => + { + calls++; + return Task.CompletedTask; + }; + + client.connection.OnTransaction += handler; + + // After the handler, for the reason spelled out in the test above: registration order is + // invocation order, and a witness that ran first would report a dispatch the handler had + // not seen yet. + int dispatched = 0; + client.connection.OnTransaction += _ => + { + Interlocked.Increment(ref dispatched); + return Task.CompletedTask; + }; + + await client.connection.OnMessage(TransactionMessage); + WaitForCount(() => Volatile.Read(ref dispatched), 1, "the first frame was never dispatched"); + Assert.AreEqual(1, calls, "sanity: the handler is attached"); + + contract.OnTransaction -= handler; + await client.connection.OnMessage(TransactionMessage); + WaitForCount(() => Volatile.Read(ref dispatched), 2, "the second frame was never dispatched"); + + Assert.AreEqual(1, calls, "removing through the client left the handler attached - its remove accessor does not reach the connection"); + } + + /// + /// connection is read-only on the contract, so a caller cannot swap the object every + /// handler is attached to and leave the stream silently unreachable. + /// + [TestMethod] + public void TestUConnectionCannotBeReplacedThroughTheContract() + { + System.Reflection.PropertyInfo property = typeof(IXrplClient).GetProperty(nameof(IXrplClient.connection)); + + Assert.IsNotNull(property); + Assert.IsNull(property.SetMethod, "a settable connection would let a caller strand every handler registered through these events on the old object"); + } +} diff --git a/Tests/Xrpl.Tests/Client/TestUNFTInfoAndHistory.cs b/Tests/Xrpl.Tests/Client/TestUNFTInfoAndHistory.cs new file mode 100644 index 00000000..4d16a8ec --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestUNFTInfoAndHistory.cs @@ -0,0 +1,202 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System.Text.Json; + +using Xrpl.Client.Json; +using Xrpl.Models.Common; +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; +using Xrpl.Models.Transactions; + +namespace Xrpl.Tests.ClientLib +{ + /// + /// The two Clio commands that answer "who owns this token" and "what happened to it" - issue #132. + /// + /// + /// + /// Neither had a model, and neither has a substitute. Ownership cannot be read out of + /// nft_sell_offers, which is the natural guess: a sale does not remove offers for the + /// token from the ledger, so offers made by a previous owner keep being returned after they can + /// no longer be accepted, and the new owner has usually made none - which is exactly the state + /// a token is in immediately after being bought. + /// + /// + /// The field names here were taken from Clio's own handlers rather than from documentation: + /// NFTInfo.cpp and NFTHistory.cpp. That matters for at least one of them - + /// Clio emits nft_serial while its own source notes the documentation calls it + /// nft_sequence. + /// + /// + [TestClass] + public class TestUNFTInfoAndHistory + { + private const string TokenId = "00190000E78F76A49DD9158FA85DA4AAD95C0767303CC4611D73BB4300C989A8"; + + [TestMethod] + public void TestUNFTInfoRequestAsksForWhatClioExpects() + { + NFTInfoRequest request = new NFTInfoRequest(TokenId) + { + LedgerIndex = new LedgerIndex(LedgerIndexType.Validated), + }; + + string json = JsonSerializer.Serialize(request, XrplJsonOptions.Default); + + StringAssert.Contains(json, "\"command\":\"nft_info\""); + StringAssert.Contains(json, "\"nft_id\":\"" + TokenId + "\""); + StringAssert.Contains(json, "\"ledger_index\":\"validated\""); + } + + /// + /// The answer, read from a body shaped the way Clio writes it. + /// + [TestMethod] + public void TestUNFTInfoReadsEveryFieldClioSends() + { + const string body = """ + { + "nft_id": "00190000E78F76A49DD9158FA85DA4AAD95C0767303CC4611D73BB4300C989A8", + "ledger_index": 270, + "owner": "rG9gdNhhCXhK1UVLbaBHzXHZzYrDVJHbAM", + "is_burned": false, + "flags": 25, + "transfer_fee": 314, + "issuer": "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + "nft_taxon": 0, + "nft_serial": 12345, + "uri": "697066733A2F2F62616679626569676479727A74357366703775646D3768753736", + "validated": true + } + """; + + NFTInfo info = JsonSerializer.Deserialize(body, XrplJsonOptions.Default); + + Assert.AreEqual(TokenId, info.NFTokenID); + Assert.AreEqual(270u, info.LedgerIndex); + Assert.AreEqual("rG9gdNhhCXhK1UVLbaBHzXHZzYrDVJHbAM", info.Owner); + Assert.IsFalse(info.IsBurned.Value, "This one is alive; a burned token has no owner to report."); + Assert.AreEqual(25u, info.Flags); + Assert.AreEqual(314u, info.TransferFee); + Assert.AreEqual("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", info.Issuer); + Assert.AreEqual(0u, info.Taxon); + Assert.AreEqual(12345u, info.Serial, "Clio sends this as nft_serial, whatever the documentation calls it."); + Assert.IsFalse(string.IsNullOrEmpty(info.URI)); + Assert.IsTrue(info.Validated.Value); + + // The other half of the claim, and the half the assertions above cannot make: that + // nothing Clio sends was missed. Reading eleven properties correctly says nothing about + // a twelfth quietly landing in UnknownFields - which is the bar this repository already + // set for modelled fields, a property declared AND the field gone from here. + Assert.IsTrue( + info.UnknownFields is null || info.UnknownFields.Count == 0, + $"nft_info fields the model does not declare: {Describe(info.UnknownFields)}"); + } + + private static string Describe(System.Collections.Generic.IDictionary unknown) => + unknown is null ? "none" : string.Join(", ", unknown.Keys); + + [TestMethod] + public void TestUNFTHistoryRequestCarriesItsPagination() + { + NFTHistoryRequest request = new NFTHistoryRequest(TokenId) + { + LedgerIndexMin = -1, + LedgerIndexMax = -1, + Limit = 200, + Forward = true, + Marker = new { ledger = 270, seq = 1 }, + }; + + string json = JsonSerializer.Serialize(request, XrplJsonOptions.Default); + + StringAssert.Contains(json, "\"command\":\"nft_history\""); + StringAssert.Contains(json, "\"nft_id\":\"" + TokenId + "\""); + StringAssert.Contains(json, "\"ledger_index_min\":-1"); + StringAssert.Contains(json, "\"ledger_index_max\":-1"); + StringAssert.Contains(json, "\"limit\":200"); + StringAssert.Contains(json, "\"forward\":true"); + StringAssert.Contains(json, "\"marker\""); + } + + /// + /// History entries are the same shape account_tx returns, so the same type reads them. + /// + /// + /// Asserted rather than assumed, because it is the reason no parallel entry type was + /// written: already handles the tx and tx_json + /// envelopes of API v1 and v2, and a second type would be a second place to keep in step + /// with rippled's envelopes. + /// + [TestMethod] + public void TestUNFTHistoryReadsItsTransactionsAndMarker() + { + const string body = """ + { + "nft_id": "00190000E78F76A49DD9158FA85DA4AAD95C0767303CC4611D73BB4300C989A8", + "ledger_index_min": 3, + "ledger_index_max": 270, + "limit": 2, + "marker": { "ledger": 265, "seq": 1 }, + "transactions": [ + { + "meta": { "TransactionResult": "tesSUCCESS" }, + "tx_json": { + "Account": "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + "TransactionType": "NFTokenMint", + "NFTokenTaxon": 0 + }, + "hash": "5F8A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D4E5F60718293A4B5C6D7E8", + "ledger_index": 270, + "validated": true + } + ], + "validated": true + } + """; + + NFTHistory history = JsonSerializer.Deserialize(body, XrplJsonOptions.Default); + + Assert.AreEqual(TokenId, history.NFTokenID); + Assert.AreEqual(3u, history.LedgerIndexMin); + Assert.AreEqual(270u, history.LedgerIndexMax); + Assert.IsNotNull(history.Marker, "A marker means there is more to read, and it must survive to be handed back."); + Assert.IsNotNull(history.Transactions); + Assert.AreEqual(1, history.Transactions.Count); + + TransactionSummary entry = history.Transactions[0]; + Assert.AreEqual("tesSUCCESS", entry.Meta?.TransactionResult); + Assert.IsNotNull(entry.Transaction, "The tx_json envelope must have been read, same as in account_tx."); + Assert.IsInstanceOfType( + entry.Transaction, + "History is read through the I-interfaces; the request type never matches what a ledger sends."); + + Assert.IsTrue( + history.UnknownFields is null || history.UnknownFields.Count == 0, + $"nft_history fields the model does not declare: {Describe(history.UnknownFields)}"); + } + + /// + /// An answer without a marker is the last page, and that has to be visible. + /// + [TestMethod] + public void TestUNFTHistoryWithoutAMarkerIsTheLastPage() + { + const string body = """ + { + "nft_id": "00190000E78F76A49DD9158FA85DA4AAD95C0767303CC4611D73BB4300C989A8", + "ledger_index_min": 3, + "ledger_index_max": 270, + "transactions": [], + "validated": true + } + """; + + NFTHistory history = JsonSerializer.Deserialize(body, XrplJsonOptions.Default); + + Assert.IsNull(history.Marker, "Without this a caller cannot tell the last page from a page that happens to be empty."); + Assert.IsNotNull(history.Transactions); + Assert.AreEqual(0, history.Transactions.Count); + } + } +} diff --git a/Tests/Xrpl.Tests/Client/TestUOnConnectedHandlerFailure.cs b/Tests/Xrpl.Tests/Client/TestUOnConnectedHandlerFailure.cs index a47f8a46..f415b46a 100644 --- a/Tests/Xrpl.Tests/Client/TestUOnConnectedHandlerFailure.cs +++ b/Tests/Xrpl.Tests/Client/TestUOnConnectedHandlerFailure.cs @@ -22,27 +22,29 @@ public class TestUOnConnectedHandlerFailure private XrplClient _client; private int _port; + private static Dictionary ServerInfoResult() => new Dictionary + { + { "type", "response" }, + { "status", "success" }, + { "result", new Dictionary + { + { "info", new Dictionary + { + { "build_version", "test-mock" }, + { "complete_ledgers", "1-1" }, + { "server_state", "full" }, + } + }, + } + }, + }; + [TestInitialize] public void MyTestInitialize() { _port = TestUtils.GetFreePort(); _mockedRippled = new CreateMockRippled(_port) { suppressOutput = true }; - _mockedRippled.AddResponse("server_info", new Dictionary - { - { "type", "response" }, - { "status", "success" }, - { "result", new Dictionary - { - { "info", new Dictionary - { - { "build_version", "test-mock" }, - { "complete_ledgers", "1-1" }, - { "server_state", "full" }, - } - }, - } - }, - }); + _mockedRippled.AddResponse("server_info", ServerInfoResult()); Thread tcpListenerThread = new Thread(() => _mockedRippled.Start()) { IsBackground = true }; tcpListenerThread.Start(); @@ -73,6 +75,127 @@ private XrplClient CreateClient(int maxReconnectAttempts, bool stopAfterMaxAttem UseCustomPing = false, }); + /// + /// A handler failure the client recovers from must not reach the caller at all - not even + /// as a different exception. The connect succeeded; saying otherwise is simply wrong. + /// + /// + /// The reconnect path exists for exactly this: a handler that fails once and works on the + /// next attempt. But the teardown in between rejects whatever the caller had in flight, and + /// the caller is inside SetNetworkId by then - so Connect() threw + /// OperationCanceledException while the client went on to connect. Measured before + /// the fix: the caller got a cancellation and IsConnected() was true three + /// seconds later. + /// + /// Asking "is it connected now?" in that catch would not have worked either: at that moment + /// the socket has just been torn down and the recovery has not finished, so the honest + /// answer is no. The wait is what makes the difference between a connection being rebuilt + /// and a client that gave up. + /// + /// + [TestMethod] + public async Task TestRecoveredHandlerFailureWithRequestInFlightStillConnects() + { + _mockedRippled.AddDelayedResponse("server_info", ServerInfoResult(), TimeSpan.FromSeconds(2)); + + _client = CreateClient(maxReconnectAttempts: 5, stopAfterMaxAttempts: true); + + int calls = 0; + _client.OnConnected += async () => + { + int call = Interlocked.Increment(ref calls); + await Task.Delay(TimeSpan.FromMilliseconds(400)); + + if (call == 1) + { + throw new InvalidOperationException("first attempt fails, the next one works"); + } + }; + + Exception connectError = null; + try + { + await _client.Connect(); + } + catch (Exception error) + { + connectError = error; + } + + Assert.IsNull( + connectError, + $"The client recovered and connected, so Connect() must not report a failure. " + + $"Got: {connectError}"); + // Both of these say the run really was the scenario this test is about, rather than a + // connect that quietly did nothing: the client ended up connected, and it got there + // through a handler that failed once and ran again. + Assert.IsTrue( + _client.connection.IsConnected(), + "The client must have ended up connected."); + Assert.IsTrue( + Volatile.Read(ref calls) >= 2, + $"The handler must have failed once and run again, but ran {Volatile.Read(ref calls)} time(s)."); + } + + /// + /// Giving up must reach the caller as even when the + /// caller had a request in flight at the moment the client gave up - issue #122. + /// + /// + /// + /// Connect() is two operations, not one: the connection itself, and the + /// server_info that SetNetworkId sends straight after it. The socket really + /// does open for a moment before the failing handler brings it down, so the wait can return + /// successfully and the caller can already be inside that second operation when the give-up + /// path tears everything down. Whatever rejects the in-flight request then decides what the + /// caller sees - and none of the candidates is the right answer: + /// OperationCanceledException says the caller cancelled something they never + /// cancelled, DisconnectedException says a working connection went away. + /// + /// + /// The delayed answer is what makes this deterministic. Answered at once, the window is + /// reachable only by luck: the assertion failed on CI about every other run and never once + /// in 37 local runs, which is why the issue sat open with the mechanism unproven. Holding + /// server_info back puts the request in flight for certain. + /// + /// + [TestMethod] + public async Task TestGivingUpWithARequestInFlightStillReportsNotConnected() + { + // Ten times longer than the give-up below takes, so the request is certainly still + // pending when it happens. + _mockedRippled.AddDelayedResponse("server_info", ServerInfoResult(), TimeSpan.FromSeconds(5)); + + _client = CreateClient(maxReconnectAttempts: 1, stopAfterMaxAttempts: true); + + // A handler that works before it fails is the realistic case - a subscribe that gets + // some way in before falling over - and it is also what makes this test mean anything. + // While it runs the socket is open, so the wait for a connection returns successfully + // and the caller reaches the second operation. With a handler that throws at once the + // client gives up first, the caller never gets there, and the test passes vacuously. + _client.OnConnected += async () => + { + await Task.Delay(TimeSpan.FromMilliseconds(400)); + throw new InvalidOperationException("handler always fails"); + }; + + Exception connectError = null; + try + { + await _client.Connect(); + } + catch (Exception error) + { + connectError = error; + } + + Assert.IsInstanceOfType( + connectError, + $"Giving up must unblock the caller with NotConnectedException whatever rejected the " + + $"request it had in flight, got: {connectError?.GetType().Name ?? "no exception"}. " + + $"Full exception: {connectError}"); + } + /// /// A transient failure inside OnConnected (e.g. a subscribe that timed out because the /// node accepts TCP before it serves requests) must not strand the client: the socket is torn @@ -155,7 +278,7 @@ public async Task TestClientIsUsableAfterOnConnectedFailure() { "command", "server_info" }, }; - Dictionary response = await _client.Request(request); + Dictionary response = await _client.Request(request).Typed(); Assert.IsNotNull(response, "Request after recovery must succeed."); } diff --git a/Tests/Xrpl.Tests/Client/TestUResponseParsing.cs b/Tests/Xrpl.Tests/Client/TestUResponseParsing.cs index ff3c5742..8a054d94 100644 --- a/Tests/Xrpl.Tests/Client/TestUResponseParsing.cs +++ b/Tests/Xrpl.Tests/Client/TestUResponseParsing.cs @@ -1,7 +1,8 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Collections.Generic; +using System.IO; using System.Text; using System.Text.Json; using System.Threading; @@ -9,8 +10,10 @@ using Xrpl.Client; using Xrpl.Client.Exceptions; +using Xrpl.Client.Json; using Xrpl.Models.Ledger; using Xrpl.Models.Methods; +using Xrpl.Models.Subscriptions; namespace Xrpl.Tests.ClientLib { @@ -76,7 +79,7 @@ public void TestUntypedRequestGetsTheParsedResultNode() manager.HandleResponse(BuildLedgerDataMessage(pending.Id, 4)); - JsonElement result = (JsonElement)pending.Promise.GetAwaiter().GetResult(); + JsonElement result = XrplResponse.From(pending.Promise.GetAwaiter().GetResult()).Result; Assert.AreEqual(JsonValueKind.Object, result.ValueKind); Assert.AreEqual(4, result.GetProperty("state").GetArrayLength()); Assert.AreEqual(96000000, result.GetProperty("ledger_index").GetInt32()); @@ -96,7 +99,7 @@ public void TestTypedRequestDeserializesFromTheParsedResultNode() manager.HandleResponse(BuildLedgerDataMessage(pending.Id, 3)); - LOLedgerData result = (LOLedgerData)pending.Promise.GetAwaiter().GetResult(); + LOLedgerData result = XrplResponse.From(pending.Promise.GetAwaiter().GetResult()).Result; Assert.IsNotNull(result); Assert.AreEqual(96000000u, result.LedgerIndex); Assert.AreEqual("842B57C1CC0613299A686D3E9F310EC0422C84D3911E5056389AA7E5808A93C8", result.LedgerHash); @@ -116,8 +119,8 @@ public void TestUtf8AndStringOverloadsProduceTheSameResult() 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(); + LOLedgerData fromString = XrplResponse.From(viaString.Promise.GetAwaiter().GetResult()).Result; + LOLedgerData fromBytes = XrplResponse.From(viaBytes.Promise.GetAwaiter().GetResult()).Result; Assert.AreEqual(fromString.LedgerIndex, fromBytes.LedgerIndex); Assert.AreEqual(fromString.LedgerHash, fromBytes.LedgerHash); @@ -131,13 +134,13 @@ public void TestResponseWithoutResultStillCompletes() RequestManager.XrplGRequest untyped = Pending(manager); manager.HandleResponse($"{{\"id\":\"{untyped.Id:D}\",\"status\":\"success\",\"type\":\"response\",\"result\":null}}"); - JsonElement empty = (JsonElement)untyped.Promise.GetAwaiter().GetResult(); + JsonElement empty = XrplResponse.From(untyped.Promise.GetAwaiter().GetResult()).Result; 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(); + LOLedgerData defaults = XrplResponse.From(typed.Promise.GetAwaiter().GetResult()).Result; Assert.IsNotNull(defaults); Assert.IsNull(defaults.State); } @@ -169,6 +172,103 @@ public void TestErrorStatusRejectsWithTheParsedErrorResponse() Assert.AreEqual("ledgerNotFound", rippled.Response.ErrorMessage); } + /// + /// The result member is no longer parsed on the way in — the envelope only records where it + /// sits — so the typed deserialization now has to cut it straight out of the frame. + /// + [TestMethod] + public void TestUTypedResultDeserializesFromTheSlice() + { + RequestManager manager = new RequestManager(); + RequestManager.XrplGRequest pending = Pending(manager); + + manager.HandleResponse(Encoding.UTF8.GetBytes(BuildLedgerDataMessage(pending.Id, 3))); + + LOLedgerData result = XrplResponse.From(pending.Promise.GetAwaiter().GetResult()).Result; + Assert.IsNotNull(result); + Assert.IsNotNull(result.Marker); + Assert.AreEqual("AABBCCDD", result.Marker.ToString()); + } + + /// A response carrying the raw frame must expose the result member byte for byte. + [TestMethod] + public void TestURawResultReproducesWhatTheNodeSent() + { + RequestManager manager = new RequestManager(); + RequestManager.XrplGRequest pending = Pending(manager); + + string message = BuildLedgerDataMessage(pending.Id, 2); + (BaseResponse response, bool handled) = manager.HandleResponse(Encoding.UTF8.GetBytes(message)); + + Assert.IsTrue(handled); + int start = message.IndexOf("\"result\":", StringComparison.Ordinal) + "\"result\":".Length; + string expected = message.Substring(start, message.Length - start - 1); + Assert.AreEqual(expected, response.RawResult.ToString()); + } + + /// + /// The response aliases the frame it was handed rather than copying it. Pinned because the + /// contract is invisible in the signature: a caller that reuses a pooled buffer would + /// rewrite a response it already handed out. + /// + [TestMethod] + public void TestUResponseAliasesTheFrameItWasGiven() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"id\":\"1\",\"result\":{\"marker\":1}}"); + RequestManager manager = new RequestManager(); + (BaseResponse response, _) = manager.HandleResponse(frame); + + Assert.AreEqual("{\"marker\":1}", response.RawResult.ToString()); + + // Index 21 is the 'm' of "marker": {"id":"1","result":{"marker":1}} counts + // 0123456789012345678901 up to that byte. + frame[21] = (byte)'z'; + + Assert.AreEqual("{\"zarker\":1}", response.RawResult.ToString()); + } + + /// + /// Bounds are only meaningful for a reader that covered one contiguous buffer, which the + /// Stream overloads do not. That path is disarmed by construction rather than by a check: + /// Frame is internal, so it stays null there and the raw result comes back empty instead of + /// pointing at bytes that were never checked. + /// + [TestMethod] + public void TestUEnvelopeParsedFromAStreamExposesNoRawResult() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"id\":\"7\",\"result\":{\"a\":1}}"); + using MemoryStream stream = new MemoryStream(frame); + + ErrorResponse envelope = JsonSerializer.Deserialize(stream, XrplJsonOptions.Default); + + Assert.IsTrue(envelope.RawResult.IsEmpty); + } + + /// An envelope built by hand has no frame, so there is nothing to hand out. + [TestMethod] + public void TestUEnvelopeWithoutFrameHasEmptyRawResult() + { + Assert.IsTrue(new ErrorResponse().RawResult.IsEmpty); + } + + /// + /// Each response reads from its own frame. The receive loop hands out a fresh exact-sized + /// array per message, and nothing downstream may collapse two of them. + /// + [TestMethod] + public void TestUEnvelopesDoNotShareAFrame() + { + byte[] first = Encoding.UTF8.GetBytes("{\"id\":\"1\",\"result\":{\"n\":1}}"); + byte[] second = Encoding.UTF8.GetBytes("{\"id\":\"2\",\"result\":{\"n\":2}}"); + + RequestManager manager = new RequestManager(); + (BaseResponse a, _) = manager.HandleResponse(first); + (BaseResponse b, _) = manager.HandleResponse(second); + + Assert.AreEqual("{\"n\":1}", a.RawResult.ToString()); + Assert.AreEqual("{\"n\":2}", b.RawResult.ToString()); + } + /// /// 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 @@ -201,7 +301,7 @@ public void TestResponseParsingStaysWithinItsAllocationBudget() RequestManager.XrplGRequest pending = Pending(manager); WriteId(message, IdOffset, pending.Id); manager.HandleResponse(message); - JsonElement result = (JsonElement)pending.Promise.GetAwaiter().GetResult(); + JsonElement result = XrplResponse.From(pending.Promise.GetAwaiter().GetResult()).Result; Assert.AreEqual(Entries, result.GetProperty("state").GetArrayLength()); } @@ -211,9 +311,13 @@ public void TestResponseParsingStaysWithinItsAllocationBudget() Console.WriteLine($"response {message.Length:N0} bytes, {perResponse / 1024 / 1024:F2} MB allocated per response ({ratio:F2}x)"); + // Measured 1.89x. Note this budget is blind to the change that removed the + // intermediate document: asking for JsonElement builds one either way, so the figure + // is identical before and after. TestTypedResponseParsingStaysWithinItsAllocationBudget + // is the one that sees it. Assert.IsTrue( - ratio < 4.0, - $"response parsing allocated {ratio:F2}x the response size, budget is 4x " + + ratio < 2.4, + $"response parsing allocated {ratio:F2}x the response size, budget is 2.4x " + "(the pre-fix double round-trip cost about 7x here)"); } @@ -392,12 +496,158 @@ private static async Task CrawlPageAsync(XrplClient client) { JsonElement page = await client .GRequest(new LedgerDataRequest { Binary = true, Limit = 2048 }) - .ConfigureAwait(false); + .Typed().ConfigureAwait(false); if (page.GetProperty("state").GetArrayLength() == 0) { throw new InvalidOperationException("ledger_data page carried no objects"); } } + + /// + /// The typed path is where removing the intermediate document shows: the result member is + /// no longer parsed into a JsonElement on the way in, only its bounds are recorded, so the + /// only parse is the one that produces the requested type. The JsonElement budget above + /// cannot see this - asking for JsonElement builds one either way. + /// + [TestMethod] + public void TestTypedResponseParsingStaysWithinItsAllocationBudget() + { + const int Entries = 4096; + const int Rounds = 12; + + RequestManager manager = new RequestManager(); + + RequestManager.XrplGRequest warmup = Pending(manager); + byte[] message = Encoding.UTF8.GetBytes(BuildLedgerDataMessage(warmup.Id, Entries)); + const int IdOffset = 7; + 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); + LOLedgerData result = XrplResponse.From(pending.Promise.GetAwaiter().GetResult()).Result; + Assert.AreEqual(Entries, result.State.Count); + } + + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + double perResponse = allocated / (double)Rounds; + double ratio = perResponse / message.Length; + + Console.WriteLine($"TYPED response {message.Length:N0} bytes, {perResponse / 1024 / 1024:F2} MB per response ({ratio:F2}x)"); + + // Measured 5.57x here against 7.45x on the commit before the result member became a + // slice - 1.6 MB less per 889 KB response. The bound sits between the two, so it fails + // if the intermediate document comes back and passes through ordinary GC jitter. + Assert.IsTrue(ratio < 6.5, $"typed path cost {ratio:F2}x of the message"); + } + + + /// + /// An envelope must not retain more than the frame it shares. Before the result member + /// became a slice, System.Text.Json built a JsonElement for it whose pooled backing array — + /// 65 536 bytes for a 36 691-byte response — was never returned to the pool, and every + /// envelope carried its own. + /// + [TestMethod] + [DoNotParallelize] + public void TestUEnvelopeRetainsNoMoreThanTheFrame() + { + byte[] frame = Encoding.UTF8.GetBytes(BuildLedgerDataMessage(Guid.NewGuid(), 200)); + + // Two thousand envelopes, not fifty. GC.GetTotalMemory is a process-wide counter and + // [DoNotParallelize] only keeps other tests in this assembly off the CPU - it does + // nothing about background threads, WebSocket servers left running by earlier tests, or + // finalizers landing inside the window. Measured directly, the noise band is on the + // order of a few kilobytes in both directions, which swamps a per-envelope cost of a + // couple of hundred bytes if the sample is small. + // + // Raising the sample size is what makes the measurement mean something: the signal + // scales with Count, the noise does not. Averaging over 2 000 envelopes puts the real + // per-envelope figure far above the per-sample jitter, and a regression - the pooled + // result document returning at 65 536 B per response - misses the bound by three + // orders of magnitude rather than hiding inside it. + // + // Filtering the noise instead of outgrowing it does not work here, and was tried: + // taking the minimum across repeats drove the reading to zero, at which point the test + // stayed green with 10 000 bytes of deliberate ballast added to every envelope. + const int Count = 2000; + + GC.Collect(2, GCCollectionMode.Forced, blocking: true); + GC.WaitForPendingFinalizers(); + GC.Collect(2, GCCollectionMode.Forced, blocking: true); + long before = GC.GetTotalMemory(true); + + List retained = new List(Count); + for (int i = 0; i < Count; i++) + { + retained.Add(JsonSerializer.Deserialize(frame, XrplJsonOptions.Default)); + } + + GC.Collect(2, GCCollectionMode.Forced, blocking: true); + GC.WaitForPendingFinalizers(); + GC.Collect(2, GCCollectionMode.Forced, blocking: true); + long perEnvelope = (GC.GetTotalMemory(true) - before) / Count; + + GC.KeepAlive(retained); + + Console.WriteLine( + $"envelope retains {perEnvelope} B on its own, averaged over {Count} (frame is {frame.Length} B, shared)"); + + // BaseResponse.Id and ErrorResponse.Request are slices now, same as result, so neither + // builds a JsonElement with an unreturned ArrayPool rental any more - the 3 455 B + // remainder that used to sit here is gone. The bound has headroom over the real + // per-envelope cost and sits far below a returning result document, which was 65 536 B + // per response on its own. + Assert.IsTrue( + perEnvelope < 6144, + $"envelope retained {perEnvelope} B on its own, averaged over {Count}; a pooled result document is back"); + } + + /// + /// Pairing is done in one call that checks the bounds against the frame, so a frame that + /// does not match the recorded slice is rejected where the two meet rather than lazily, + /// inside a consumer's read. + /// + [TestMethod] + public void TestUAttachFrameRejectsAFrameThatDoesNotFitTheSlice() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"id\":\"7\",\"result\":{\"a\":1}}"); + ErrorResponse envelope = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); + + Assert.ThrowsExactly(() => envelope.AttachFrame(Encoding.UTF8.GetBytes("{}"))); + } + + /// The other half of pairing: a missing frame is rejected too, not just a short one. + [TestMethod] + public void TestUAttachFrameRejectsANullFrame() + { + Assert.ThrowsExactly(() => new ErrorResponse().AttachFrame(null)); + } + + /// + /// A frame holding the bare JSON literal null (not an empty/missing frame - the byte + /// content of the frame itself is the four characters "null") deserializes to a null + /// rather than throwing. + /// must catch that and raise a typed protocol error instead of letting the next line's + /// response.AttachFrame(frame) throw a bare . + /// + [TestMethod] + public void TestUHandleResponseRejectsAJsonNullFrame() + { + RequestManager manager = new RequestManager(); + + XrplException raised = Assert.ThrowsExactly( + () => manager.HandleResponse(Encoding.UTF8.GetBytes("null"))); + + StringAssert.Contains(raised.Message, "null"); + } + } } diff --git a/Tests/Xrpl.Tests/Client/TestUSessionEndedNotification.cs b/Tests/Xrpl.Tests/Client/TestUSessionEndedNotification.cs new file mode 100644 index 00000000..4c5457fc --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestUSessionEndedNotification.cs @@ -0,0 +1,393 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using Xrpl.Client; + +namespace Xrpl.Tests +{ + /// + /// Regression tests for issue #123: a session can end without the consumer being told, and the + /// subscriptions the node held against it are gone the moment it does. + /// + /// + /// + /// ChangeServer was the silent case. It marks the old session retiring, which makes the + /// socket's own close callback return early on purpose, and the only notification it sends is a + /// Connecting status - the same one a first connection sends. A consumer that resubscribed + /// on disconnect therefore never resubscribed, the client went on reporting Connected, and + /// the stream stayed dead for good with nothing in the API saying why. + /// + /// + /// is the one signal that covers every way a session can end, so + /// these tests check all of them rather than only the reported one. + /// + /// + [TestClass] + public class TestUSessionEndedNotification + { + private CreateMockRippled _mockedRippled; + private CreateMockRippled _secondRippled; + private XrplClient _client; + private int _port; + + private static Dictionary ServerInfoResponse() => new Dictionary + { + { "type", "response" }, + { "status", "success" }, + { "result", new Dictionary + { + { "info", new Dictionary + { + { "build_version", "test-mock" }, + { "complete_ledgers", "1-1" }, + { "server_state", "full" }, + } + }, + } + }, + }; + + private static CreateMockRippled StartMock(int port) + { + CreateMockRippled mock = new CreateMockRippled(port) { suppressOutput = true }; + mock.AddResponse("server_info", ServerInfoResponse()); + + Thread listenerThread = new Thread(() => mock.Start()) { IsBackground = true }; + listenerThread.Start(); + return mock; + } + + private XrplClient CreateClient(string url, XrplClient.ClientOptions options = null) + { + return new XrplClient(url, options ?? new XrplClient.ClientOptions + { + RequestPolicy = RequestFailurePolicy.ImmediateFail, + ReconnectBaseDelay = TimeSpan.FromMilliseconds(200), + ReconnectMaxDelay = TimeSpan.FromSeconds(1), + ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(3), + ConnectionAttemptTimeout = TimeSpan.FromSeconds(3), + UseCustomPing = false, + }); + } + + /// + /// Waits for , polling; returns as soon as it holds. + /// + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + DateTime deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline && !condition()) + { + await Task.Delay(TimeSpan.FromMilliseconds(50)); + } + } + + [TestInitialize] + public void MyTestInitialize() + { + _port = TestUtils.GetFreePort(); + _mockedRippled = StartMock(_port); + } + + [TestCleanup] + public async Task MyTestCleanup() + { + if (_client != null) + { + await _client.Disconnect(); + _client = null; + } + + _mockedRippled?.Stop(); + _secondRippled?.Stop(); + } + + /// + /// The reported case: switching servers from a live connection must say that the session - + /// and with it the consumer's subscriptions - has ended. + /// + [TestMethod] + public async Task TestChangeServerAnnouncesThatTheSessionEnded() + { + int secondPort = TestUtils.GetFreePort(); + _secondRippled = StartMock(secondPort); + + _client = CreateClient($"ws://127.0.0.1:{_port}"); + + List sequence = new List(); + object sequenceLock = new object(); + + void Record(string entry) + { + lock (sequenceLock) + { + sequence.Add(entry); + } + } + + List reasons = new List(); + List descriptions = new List(); + + // Recorded, not asserted, inside the handler: a handler that throws is contained by + // design, so an assertion failing in here would vanish instead of failing the test. + _client.OnSessionEnded += (reason, description) => + { + lock (sequenceLock) + { + reasons.Add(reason); + descriptions.Add(description); + } + + Record($"ended:{reason}"); + return Task.CompletedTask; + }; + _client.OnConnected += () => + { + Record("connected"); + return Task.CompletedTask; + }; + + await _client.Connect(); + Assert.IsTrue(_client.connection.IsConnected(), "Precondition: the client must be on the first server."); + + await _client.ChangeServer($"ws://127.0.0.1:{secondPort}"); + Assert.IsTrue(_client.connection.IsConnected(), "Precondition: the client must reach the second server."); + + // The old socket closes in the background, after ChangeServer has returned. Give that + // close time to arrive: it reaches the same announcement path, and a second event for + // one session would have the consumer resubscribe twice. + await Task.Delay(TimeSpan.FromSeconds(1)); + + List observed; + lock (sequenceLock) + { + observed = new List(sequence); + } + + Assert.AreEqual( + 1, + reasons.Count, + $"A session ends once and must be announced once. Observed: {string.Join(", ", observed)}."); + Assert.AreEqual( + SessionEndReason.ServerChanged, + reasons[0], + "ChangeServer is what ended this session."); + Assert.IsFalse( + string.IsNullOrWhiteSpace(descriptions[0]), + "The description is what a consumer puts in its log; it must not be empty."); + + // Order matters as much as the event itself: a consumer told about the loss only after + // OnConnected for the new session would resubscribe and then be told its subscriptions + // are gone - and would stop, exactly where it started. + CollectionAssert.AreEqual( + new List { "connected", $"ended:{SessionEndReason.ServerChanged}", "connected" }, + observed, + $"The end of the old session must be announced before the new one connects. Observed: {string.Join(", ", observed)}."); + } + + /// + /// A caller closing the connection ends the session too, and is told so with a reason that + /// says it was their own doing. + /// + /// + /// + /// Both announcements are asserted, and the point is that each comes exactly once however + /// the receive loop happens to end. Cancelling a parked receive throws and the loop reports + /// the close from a catch; when the response that woke Connect() resumed the caller + /// inline on the receive-loop thread instead, the close happens inside that continuation + /// and the loop leaves by its while condition. That exit used to report nothing at + /// all - the silent path this class was written to close - and now reports the same way. + /// + /// + /// Fixing that exit is what makes this path work at all: the session-ended announcement on + /// a user close rides on the same callback, so while the loop was silent the consumer heard + /// neither event. + /// + /// + [TestMethod] + public async Task TestUserDisconnectAnnouncesThatTheSessionEnded() + { + _client = CreateClient($"ws://127.0.0.1:{_port}"); + + List reasons = new List(); + object gate = new object(); + int disconnects = 0; + _client.OnSessionEnded += (reason, description) => + { + lock (gate) + { + reasons.Add(reason); + } + + return Task.CompletedTask; + }; + _client.OnDisconnect += (code, description) => + { + Interlocked.Increment(ref disconnects); + return Task.CompletedTask; + }; + + await _client.Connect(); + Assert.IsTrue(_client.connection.IsConnected(), "Precondition: the client must be connected."); + + await _client.Disconnect(); + _client = null; // Already down; cleanup must not disconnect it a second time. + + // Disconnect() returns as soon as it has asked the socket to close; the callback that + // reports it lands afterwards. The fixed wait that follows is not for the first event + // but for a second: both counts below are asserted to be one, and a duplicate needs + // somewhere to show up. + await WaitUntilAsync(() => { lock (gate) { return reasons.Count > 0; } }, TimeSpan.FromSeconds(10)); + await Task.Delay(TimeSpan.FromMilliseconds(500)); + + lock (gate) + { + Assert.AreEqual( + 1, + reasons.Count, + $"A user disconnect ends exactly one session (OnDisconnect fired {Volatile.Read(ref disconnects)} time(s))."); + Assert.AreEqual( + SessionEndReason.UserDisconnected, + reasons[0], + "Nothing failed here - the caller asked for it, and a consumer may well want to tell the difference."); + } + + Assert.AreEqual( + 1, + Volatile.Read(ref disconnects), + "A closed socket is reported once, whichever way the receive loop ended."); + } + + /// + /// A connection attempt that never succeeded had no subscriptions, so there is nothing to + /// announce - and announcing it anyway would have consumers resubscribing against a client + /// that was never up, once per retry. + /// + /// + /// + /// A session object exists all the same: it is created before ws.Connect() is + /// called, so it long outlives the question of whether a connection happened. The failed + /// attempt still reaches the close callback - closes below is two even here - which + /// is why the announcement has to ask whether the socket ever opened rather than whether a + /// session exists. + /// + /// + /// This test is worth less on Windows than on Linux. Whether the close callback + /// arrives while its session is still the active one is a matter of timing: on Linux the + /// ids match and the defect showed as three announcements for three retries, which is how + /// CI caught it; on Windows the next retry has already installed its session by then, the + /// ids miss, and the count is zero with or without the fix. Removing the fix locally and + /// rerunning is therefore not a check - it passes either way here. + /// + /// + [TestMethod] + public async Task TestFailedConnectAnnouncesNothing() + { + int deadPort = TestUtils.GetFreePort(); // nothing is listening there + + _client = CreateClient($"ws://127.0.0.1:{deadPort}", new XrplClient.ClientOptions + { + RequestPolicy = RequestFailurePolicy.ImmediateFail, + ReconnectBaseDelay = TimeSpan.FromMilliseconds(100), + ReconnectMaxDelay = TimeSpan.FromMilliseconds(200), + MaxReconnectAttempts = 2, + StopAfterMaxAttempts = true, + ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(2), + ConnectionAttemptTimeout = TimeSpan.FromSeconds(2), + UseCustomPing = false, + }); + + int announced = 0; + int closes = 0; + _client.OnSessionEnded += (reason, description) => + { + Interlocked.Increment(ref announced); + return Task.CompletedTask; + }; + _client.OnDisconnect += (code, description) => + { + Interlocked.Increment(ref closes); + return Task.CompletedTask; + }; + + try + { + await _client.Connect(); + } + catch (Exception) + { + // Expected - nothing is listening. What matters is that no session was announced. + } + + await Task.Delay(TimeSpan.FromSeconds(1)); + + Assert.IsTrue( + Volatile.Read(ref closes) > 0, + "The attempt must actually have failed and been reported, or this test proves nothing."); + Assert.AreEqual( + 0, + Volatile.Read(ref announced), + "No session was ever established, so none can have ended."); + } + + /// + /// The fast-reconnect path - a ping timeout or a dead-quiet socket - retires the session + /// just as ChangeServer does, and was just as quiet about the subscriptions going + /// with it. Its RestoringConnection status says the connection is being rebuilt, not + /// that everything bound to the old one is gone. + /// + [TestMethod] + public async Task TestFastReconnectAnnouncesThatTheSessionEnded() + { + using SilentOnPingServer server = new SilentOnPingServer(); + + _client = CreateClient(server.Url, new XrplClient.ClientOptions + { + RequestPolicy = RequestFailurePolicy.ImmediateFail, + ReconnectBaseDelay = TimeSpan.FromMilliseconds(100), + ReconnectMaxDelay = TimeSpan.FromMilliseconds(500), + ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(3), + ConnectionAttemptTimeout = TimeSpan.FromSeconds(3), + // The two knobs that exist so this path is reachable from a test in under a second + // rather than after a minute of real silence. + UseCustomPing = true, + HealthCheckInterval = TimeSpan.FromMilliseconds(200), + InactivityTimeout = TimeSpan.FromMilliseconds(500), + }); + + List reasons = new List(); + object gate = new object(); + _client.OnSessionEnded += (reason, description) => + { + lock (gate) + { + reasons.Add(reason); + } + + return Task.CompletedTask; + }; + + await _client.Connect(); + Assert.IsTrue(_client.connection.IsConnected(), "Precondition: the client must be connected."); + + // The server never answers a ping, so nothing arrives, the health check sees silence + // past the inactivity limit and hands the connection to the fast-reconnect path. + await WaitUntilAsync(() => { lock (gate) { return reasons.Count > 0; } }, TimeSpan.FromSeconds(15)); + + lock (gate) + { + Assert.IsTrue( + reasons.Count > 0, + "A session retired by the fast-reconnect path must be announced like any other."); + Assert.AreEqual( + SessionEndReason.ConnectionLost, + reasons[0], + "Nobody asked for this one - the connection went quiet and the SDK rebuilt it."); + } + } + } +} diff --git a/Tests/Xrpl.Tests/Client/TestUStaleSessionFrames.cs b/Tests/Xrpl.Tests/Client/TestUStaleSessionFrames.cs new file mode 100644 index 00000000..5d36f59f --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestUStaleSessionFrames.cs @@ -0,0 +1,478 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Xrpl.Client; +using Xrpl.Models.Subscriptions; +using Xrpl.Tests; + +namespace XrplTests.Client; + +/// +/// A frame from a socket that is being retired must not reach handlers as if it were current. +/// +/// +/// Retirement is not instant: RetireOldSessionAsync runs fire-and-forget alongside the new +/// connection and closes the old socket gracefully, so that socket keeps delivering for as long as +/// the close handshake takes - with its message callback still attached. After a reconnect those +/// frames are merely stale. After a ChangeServer between networks they describe a different +/// chain: transactions for accounts that do not exist on the new one, ledger indexes from +/// somewhere else entirely. +/// +/// The lifecycle callbacks have always compared their captured session against the active one; +/// the message path was the exception, carrying no session at all. +/// +/// +[TestClass] +public class TestUStaleSessionFrames +{ + private const string ScriptedReply = + "{\"id\":__ID__,\"status\":\"success\",\"type\":\"response\",\"result\":{}}"; + + private const string TransactionMessage = """ + { + "type": "transaction", + "status": "closed", + "validated": true, + "engine_result": "tesSUCCESS", + "tx_json": { "TransactionType": "Payment", "Account": "rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd", "Sequence": 9 }, + "meta": { "AffectedNodes": [], "TransactionIndex": 0, "TransactionResult": "tesSUCCESS" } + } + """; + + /// + /// The same message with a sequence number a test can recognise. + /// + private static string TransactionMessageWith(uint sequence) => + TransactionMessage.Replace("\"Sequence\": 9", $"\"Sequence\": {sequence}"); + + private const uint DroppedSequence = 9; + + private const uint WitnessSequence = 77; + + /// + /// Waits until the handler has seen the witness frame, then reports whether it also saw the + /// frame that was supposed to be dropped. + /// + /// + /// A bare "the handler was not called" assertion cannot fail: dispatch is asynchronous on both + /// paths, so an undropped frame would simply not have arrived yet when the assertion ran. The + /// witness fixes that. It carries no session - OnMessage, which is never rejected - and + /// is submitted after the frame under test, so the single reader would have dispatched that + /// one first had it been queued at all. Seeing the witness therefore means the other one is + /// never coming. + /// + private static async Task DriveWitnessAndWait(XrplClient client, ConcurrentQueue seen) + { + await client.connection.OnMessage(TransactionMessageWith(WitnessSequence)); + + DateTime deadline = DateTime.UtcNow.AddSeconds(5); + while (!seen.Contains(WitnessSequence) && DateTime.UtcNow < deadline) + { + await Task.Delay(10); + } + + Assert.IsTrue(seen.Contains(WitnessSequence), + "the witness frame never arrived, so nothing here can be concluded about the other one"); + } + + /// + /// Waits until stream frames are queued rather than taking the fallback path. + /// + /// + /// Connect() returning implies this now - OnceOpen starts the processor before it + /// resolves the waiters - so the wait is a guard rather than a necessity. It earns its place by + /// failing here, with a sentence saying so, if that ordering ever regresses: without it these + /// tests would quietly exercise the fallback path instead and still pass. + /// + private static async Task WaitForMessageProcessor(XrplClient client) + { + DateTime deadline = DateTime.UtcNow.AddSeconds(5); + while (!client.connection.IsMessageProcessorRunning && DateTime.UtcNow < deadline) + { + await Task.Delay(10); + } + + Assert.IsTrue(client.connection.IsMessageProcessorRunning, + "the message processor never started"); + } + + /// + /// A frame tagged with a session that is not the active one is dropped and counted. + /// + [TestMethod] + public async Task TestUFrameFromARetiredSessionNeverReachesHandlers() + { + using ScriptedResponseServer server = new ScriptedResponseServer(ScriptedReply); + using XrplClient client = new XrplClient(server.Url); + await client.Connect(); + await WaitForMessageProcessor(client); + + ConcurrentQueue seen = new ConcurrentQueue(); + client.OnTransaction += r => + { + seen.Enqueue(r.Transaction.Sequence ?? 0); + return Task.CompletedTask; + }; + + try + { + // long.MaxValue stands in for a session id that is no longer active - sessions are + // numbered upward from the first connection, so this can never be the live one. + await client.connection.IOnMessageFastPath( + Encoding.UTF8.GetBytes(TransactionMessage), + sessionId: long.MaxValue); + + Assert.AreEqual(1L, client.connection.StaleSessionFramesDropped, + "a frame from a session that is not active must be dropped before it reaches the queue"); + + await DriveWitnessAndWait(client, seen); + Assert.IsFalse(seen.Contains(DroppedSequence), + "the handler saw a frame belonging to a connection that is being retired"); + } + finally + { + await client.Disconnect(); + } + } + + /// + /// A frame carrying the id of the session that is being retired is dropped too. + /// + /// + /// This is the case a plain id comparison misses. ChangeServer and the reconnect loop + /// mark the session retiring while it is still the active one - its replacement is installed + /// later, by ConnectInternalAsync - so frames arriving in that window carry an id that + /// matches. Whether the new connection is up yet decides nothing about whether these frames + /// belong to it. + /// + [TestMethod] + public async Task TestUFrameFromTheSessionBeingRetiredIsDropped() + { + using ScriptedResponseServer server = new ScriptedResponseServer(ScriptedReply); + using XrplClient client = new XrplClient(server.Url); + await client.Connect(); + await WaitForMessageProcessor(client); + + ConcurrentQueue seen = new ConcurrentQueue(); + client.OnTransaction += r => + { + seen.Enqueue(r.Transaction.Sequence ?? 0); + return Task.CompletedTask; + }; + + try + { + // Marked and named in one lock: reading the id separately would let a reconnect swap + // the session in between, and the test would quietly fall back to the mismatch case. + long? retiringSessionId = client.connection.MarkActiveSessionRetiringForTests(); + Assert.IsNotNull(retiringSessionId, "a connected client must have a session to retire"); + + await client.connection.IOnMessageFastPath( + Encoding.UTF8.GetBytes(TransactionMessage), + retiringSessionId); + + Assert.AreEqual(1L, client.connection.StaleSessionFramesDropped, + "an id that matches a retiring session is not an id that matches the live one"); + + await DriveWitnessAndWait(client, seen); + Assert.IsFalse(seen.Contains(DroppedSequence), + "the handler saw a frame from the session being retired"); + } + finally + { + await client.Disconnect(); + } + } + + /// + /// A frame that was queued while its session was live is still dropped if the session stops + /// being live before the frame is dispatched. + /// + /// + /// Checking on the way into the queue cannot be the guarantee. The queue holds up to + /// StreamMessageQueueCapacity frames (10 000 by default) and the channel itself is + /// rebuilt per session under a different lock, so between the check and the handler call the + /// session can retire and the client can be on another network entirely. + /// + /// Made deterministic by stalling the reader: the processor is a single reader that awaits + /// each handler, so a handler that does not return holds the second frame in the queue for as + /// long as the test needs. + /// + /// + [TestMethod] + public async Task TestUFrameQueuedBeforeRetirementIsNotDispatchedAfterIt() + { + using ScriptedResponseServer server = new ScriptedResponseServer(ScriptedReply); + using XrplClient client = new XrplClient(server.Url); + await client.Connect(); + await WaitForMessageProcessor(client); + + TaskCompletionSource firstFrameEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource releaseFirstFrame = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + int calls = 0; + + client.OnTransaction += async _ => + { + if (Interlocked.Increment(ref calls) == 1) + { + firstFrameEntered.TrySetResult(); + await releaseFirstFrame.Task; + } + }; + + try + { + long? sessionId = client.connection.ActiveSessionId; + Assert.IsNotNull(sessionId, "a connected client must have a session"); + + // Frame one occupies the reader and parks it inside the handler. + await client.connection.IOnMessageFastPath( + Encoding.UTF8.GetBytes(TransactionMessage), sessionId); + + Task entered = await Task.WhenAny(firstFrameEntered.Task, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.AreSame(firstFrameEntered.Task, entered, "the reader never reached the handler"); + + // Frame two is accepted into the queue - the session is still live at this point - and + // waits there because the reader is parked. + await client.connection.IOnMessageFastPath( + Encoding.UTF8.GetBytes(TransactionMessage), sessionId); + Assert.AreEqual(0L, client.connection.StaleSessionFramesDropped, + "both frames were queued while the session was live"); + + // Only now does the session stop being the live one. + client.connection.MarkActiveSessionRetiringForTests(); + releaseFirstFrame.TrySetResult(); + + // The reader wakes, takes frame two and must refuse it. + DateTime deadline = DateTime.UtcNow.AddSeconds(5); + while (client.connection.StaleSessionFramesDropped == 0 && DateTime.UtcNow < deadline) + { + await Task.Delay(20); + } + + Assert.AreEqual(1L, client.connection.StaleSessionFramesDropped, + "a frame dequeued after its session retired must not be dispatched"); + Assert.AreEqual(1, Volatile.Read(ref calls), + "the handler saw a frame belonging to a session the client had already left"); + } + finally + { + releaseFirstFrame.TrySetResult(); + await client.Disconnect(); + } + } + + /// + /// A frame the channel refuses is still delivered, through the fallback path. + /// + /// + /// The bounded channel uses DropOldest, so a full queue is not a refusal - it evicts and + /// reports success. TryWrite returns only for a completed + /// writer, which StopMessageProcessorInternal produces after clearing the field: anyone + /// holding the reference from an instant earlier writes into a closed channel. Since + /// StartPingTimer stops the processor and StartMessageProcessor rebuilds it on + /// every connect, this is the ordinary path rather than a corner of it, and a frame lost here + /// would be lost silently. + /// + [TestMethod] + public async Task TestUFrameRefusedByACompletedChannelTakesTheFallbackPath() + { + using ScriptedResponseServer server = new ScriptedResponseServer(ScriptedReply); + using XrplClient client = new XrplClient(server.Url); + await client.Connect(); + await WaitForMessageProcessor(client); + + TaskCompletionSource received = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + client.OnTransaction += r => + { + received.TrySetResult(r); + return Task.CompletedTask; + }; + + try + { + long? sessionId = client.connection.ActiveSessionId; + Assert.IsNotNull(sessionId, "a connected client must have a session"); + + // A baseline, not an absolute: the counter runs for the life of the connection, and + // the startup window this file documents elsewhere can have sent frames round the + // queue before the test got here. + long fallbackBefore = client.connection.FallbackDispatchedStreamMessages; + + client.connection.CompleteStreamChannelWriterForTests(); + + await client.connection.IOnMessageFastPath( + Encoding.UTF8.GetBytes(TransactionMessage), sessionId); + + Task completed = await Task.WhenAny(received.Task, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.AreSame(received.Task, completed, + "a frame the channel refused was neither queued nor dispatched - it vanished"); + + Assert.AreEqual(0L, client.connection.StaleSessionFramesDropped, + "the session was live throughout; nothing here is stale"); + + Assert.AreEqual(1L, client.connection.FallbackDispatchedStreamMessages - fallbackBefore, + "a frame that went round the queue must say so - that is what the counter is for"); + } + finally + { + await client.Disconnect(); + } + } + + /// + /// The fallback path hands the frame off before doing any of the work, so the receive loop + /// does not synchronously parse JSON or run handlers. + /// + /// + /// An async method runs on its caller's thread up to the first real await, and the first real + /// await inside ProcessStreamMessageAsync comes after JsonSerializer.Deserialize. + /// Without a yield at the top of the fallback, everything up to a handler's own first await + /// runs inline on the socket callback - the head-of-line blocking the queue exists to prevent, + /// reintroduced for the startup window, a stopped processor and a refused write. + /// + /// Deterministic by construction: the handler parks on a blocking wait. If the frame were + /// processed inline, the injecting call could not return until the handler was released, so + /// the wait below would time out rather than merely be slow. + /// + /// + [TestMethod] + public async Task TestUFallbackPathReturnsBeforeHandlersRun() + { + using ScriptedResponseServer server = new ScriptedResponseServer(ScriptedReply); + using XrplClient client = new XrplClient(server.Url); + await client.Connect(); + await WaitForMessageProcessor(client); + + using ManualResetEventSlim handlerEntered = new ManualResetEventSlim(initialState: false); + using ManualResetEventSlim releaseHandler = new ManualResetEventSlim(initialState: false); + + client.OnTransaction += _ => + { + handlerEntered.Set(); + releaseHandler.Wait(TimeSpan.FromSeconds(10)); + return Task.CompletedTask; + }; + + try + { + long? sessionId = client.connection.ActiveSessionId; + Assert.IsNotNull(sessionId, "a connected client must have a session"); + + // Completing the writer forces the next frame onto the fallback path. + client.connection.CompleteStreamChannelWriterForTests(); + + // Injected from a task of its own: were the frame processed inline, the call itself + // would block and there would be no Task to wait on. + Task inject = Task.Run(() => client.connection.IOnMessageFastPath( + Encoding.UTF8.GetBytes(TransactionMessage), sessionId)); + + Task finished = await Task.WhenAny(inject, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.AreSame(inject, finished, + "the fallback ran the frame inline: injection did not return while the handler was parked"); + + Assert.IsTrue(handlerEntered.Wait(TimeSpan.FromSeconds(5)), + "the frame never reached the handler at all"); + } + finally + { + releaseHandler.Set(); + await client.Disconnect(); + } + } + + /// + /// A frame naming the live session is delivered. + /// + /// + /// The sessionless case below cannot stand in for this one: a guard that rejected every named + /// session would pass it and still take the whole stream down, since every frame the socket + /// produces is named. + /// + [TestMethod] + public async Task TestUFrameNamingTheLiveSessionIsDelivered() + { + using ScriptedResponseServer server = new ScriptedResponseServer(ScriptedReply); + using XrplClient client = new XrplClient(server.Url); + await client.Connect(); + await WaitForMessageProcessor(client); + + TaskCompletionSource received = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + client.OnTransaction += r => + { + received.TrySetResult(r); + return Task.CompletedTask; + }; + + try + { + long? sessionId = client.connection.ActiveSessionId; + Assert.IsNotNull(sessionId, "a connected client must have a session"); + + await client.connection.IOnMessageFastPath( + Encoding.UTF8.GetBytes(TransactionMessage), sessionId); + + Task completed = await Task.WhenAny(received.Task, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.AreSame(received.Task, completed, + "a frame from the session the client is actually on was dropped"); + + Assert.AreEqual(0L, client.connection.StaleSessionFramesDropped, + "nothing here came from a session the client had left"); + } + finally + { + await client.Disconnect(); + } + } + + /// + /// A frame that names no session is delivered. + /// + /// + /// OnMessage is public and has no session to name, so there is nothing to compare it + /// against. Rejecting it for want of a session would break the one entry point a caller can + /// drive by hand. + /// + [TestMethod] + public async Task TestUFrameNamingNoSessionIsDelivered() + { + using ScriptedResponseServer server = new ScriptedResponseServer(ScriptedReply); + using XrplClient client = new XrplClient(server.Url); + await client.Connect(); + + TaskCompletionSource received = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + client.OnTransaction += r => + { + received.TrySetResult(r); + return Task.CompletedTask; + }; + + try + { + // No session named: the ordinary OnMessage entry point, which anyone may call and + // which has no session to compare against. It must be delivered, not dropped. + await client.connection.OnMessage(TransactionMessage); + + Task completed = await Task.WhenAny(received.Task, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.AreSame(received.Task, completed, "a frame with no session attached was dropped"); + + Assert.AreEqual(0L, client.connection.StaleSessionFramesDropped, + "nothing here came from a retired session"); + } + finally + { + await client.Disconnect(); + } + } +} diff --git a/Tests/Xrpl.Tests/Client/TestUStreamMessageDropVisibility.cs b/Tests/Xrpl.Tests/Client/TestUStreamMessageDropVisibility.cs new file mode 100644 index 00000000..9c198438 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestUStreamMessageDropVisibility.cs @@ -0,0 +1,147 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Threading; +using System.Threading.Tasks; + +using Xrpl.Client; +using Xrpl.Models.Subscriptions; +using Xrpl.Tests; + +namespace XrplTests.Client; + +/// +/// Stream messages discarded because handlers fell behind are counted, not lost in silence. +/// +/// +/// The queue feeding stream handlers is bounded and drops its oldest entry when full, so a slow +/// handler costs events rather than stalling the socket. That trade is right, but it used to leave +/// no trace at all: nothing threw, nothing logged, and a consumer building state from the stream +/// drifted from the ledger with no way to tell. DroppedStreamMessages is that trace. +/// +[TestClass] +public class TestUStreamMessageDropVisibility +{ + private const string TransactionMessage = """ + { + "type": "transaction", + "status": "closed", + "validated": true, + "engine_result": "tesSUCCESS", + "tx_json": { "TransactionType": "Payment", "Account": "rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd", "Sequence": 1 }, + "meta": { "AffectedNodes": [], "TransactionIndex": 0, "TransactionResult": "tesSUCCESS" } + } + """; + + /// + /// A handler that never returns backs the queue up; past its capacity the oldest messages are + /// discarded and counted. + /// + /// + /// Capacity is set to 2 through + /// so the case is reached deterministically instead of by pushing ten thousand messages and + /// hoping. The blocked handler is the realistic shape of the problem: a consumer doing + /// something slow per event. + /// + [TestMethod] + public async Task TestUDroppedStreamMessagesCountsWhatTheConsumerNeverSaw() + { + // Connected on purpose: the bounded queue only exists once StartMessageProcessor has run, + // which happens on a successful connect. Without it EnqueueStreamMessage falls back to + // dispatching each message directly, there is no queue to overflow, and this test passes + // while proving nothing - which is exactly what it did at first. + using ScriptedResponseServer server = new ScriptedResponseServer( + "{\"id\":__ID__,\"status\":\"success\",\"type\":\"response\",\"result\":{}}"); + using XrplClient client = new XrplClient( + server.Url, + new XrplClient.ClientOptions { StreamMessageQueueCapacity = 2 }); + await client.Connect(); + + TaskCompletionSource blocked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource firstArrived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + client.OnTransaction += async _ => + { + firstArrived.TrySetResult(); + await blocked.Task; + }; + + try + { + Assert.AreEqual(0L, client.DroppedStreamMessages, "nothing has been dropped before the queue is exercised"); + + // The first message occupies the handler; everything after it queues behind. + await client.connection.OnMessage(TransactionMessage); + await firstArrived.Task; + + for (int i = 0; i < 12; i++) + { + await client.connection.OnMessage(TransactionMessage); + } + + // Exact, not just non-zero: the reader took the first frame and is stuck on it, so the + // twelve that follow meet a queue of capacity two - ten of them evict. A >0 assertion + // would also pass if the callback missed some. + Assert.AreEqual( + 10L, + client.DroppedStreamMessages, + $"twelve writes into a capacity-two queue behind a blocked handler should evict exactly ten, found {client.DroppedStreamMessages}"); + } + finally + { + // Released here, not after the assertion: a failed assertion would otherwise leave the + // handler parked on an unfinished task and the client connected. + blocked.TrySetResult(); + await client.Disconnect(); + } + } + + /// + /// A consumer that keeps up loses nothing, so the counter stays at zero. + /// + /// + /// Without this, a counter wired to increment unconditionally would pass the test above. + /// + [TestMethod] + public async Task TestUDroppedStreamMessagesStaysZeroWhenTheConsumerKeepsUp() + { + using ScriptedResponseServer server = new ScriptedResponseServer( + "{\"id\":__ID__,\"status\":\"success\",\"type\":\"response\",\"result\":{}}"); + using XrplClient client = new XrplClient(server.Url); + await client.Connect(); + + const int Sent = 20; + + int seen = 0; + TaskCompletionSource allArrived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + client.OnTransaction += _ => + { + if (Interlocked.Increment(ref seen) == Sent) + { + allArrived.TrySetResult(); + } + + return Task.CompletedTask; + }; + + try + { + for (int i = 0; i < Sent; i++) + { + await client.connection.OnMessage(TransactionMessage); + } + + // Awaited, not asserted straight away: delivery is asynchronous now that messages + // travel through the queue, so reading the counter immediately after sending measures + // nothing. + Task completed = await Task.WhenAny(allArrived.Task, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.AreSame(allArrived.Task, completed, $"only {Volatile.Read(ref seen)} of {Sent} messages reached the handler"); + + Assert.AreEqual(0L, client.DroppedStreamMessages, "the handler returned immediately every time - nothing should have been discarded"); + } + finally + { + await client.Disconnect(); + } + } +} diff --git a/Tests/Xrpl.Tests/Client/TestUStreamProcessorStartsBeforeCallbacks.cs b/Tests/Xrpl.Tests/Client/TestUStreamProcessorStartsBeforeCallbacks.cs new file mode 100644 index 00000000..17d715e9 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestUStreamProcessorStartsBeforeCallbacks.cs @@ -0,0 +1,159 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Xrpl.Client; +using Xrpl.Models.Subscriptions; +using Xrpl.Tests; + +namespace XrplTests.Client; + +/// +/// Frames answered while the OnConnected callback is still running go through the queue, +/// like every other frame. +/// +/// +/// Subscribing from OnConnected is the ordinary pattern - it is what the wallet consuming +/// this SDK does - and the node can answer that subscription before the handler returns. The +/// message processor used to start at the very end of OnceOpen, after the callback, so +/// those first frames found no channel and took the fallback: outside +/// StreamMessageQueueCapacity, uncounted by DroppedStreamMessages, and dispatched +/// concurrently rather than one at a time. The events most likely to arrive out of order were +/// precisely the first ones after connecting. +/// +/// Moving the start earlier was blocked by an unrelated coupling: StartPingTimer begins with +/// StopPingTimerSync, which stopped the message processor as well, so an earlier start was +/// torn down again moments later. The two lifecycles are now separate. +/// +/// +[TestClass] +public class TestUStreamProcessorStartsBeforeCallbacks +{ + private const string ScriptedReply = + "{\"id\":__ID__,\"status\":\"success\",\"type\":\"response\",\"result\":{}}"; + + private const string TransactionMessage = """ + { + "type": "transaction", + "status": "closed", + "validated": true, + "engine_result": "tesSUCCESS", + "tx_json": { "TransactionType": "Payment", "Account": "rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd", "Sequence": 4 }, + "meta": { "AffectedNodes": [], "TransactionIndex": 0, "TransactionResult": "tesSUCCESS" } + } + """; + + /// + /// A frame driven from inside the OnConnected handler reaches the queue, not the + /// fallback. + /// + /// + /// FallbackDispatchedStreamMessages is the whole assertion: it counts frames dispatched + /// outside the queue, so before this change it would have counted this one. + /// + [TestMethod] + public async Task TestUFrameAnsweredDuringOnConnectedGoesThroughTheQueue() + { + using ScriptedResponseServer server = new ScriptedResponseServer(ScriptedReply); + using XrplClient client = new XrplClient(server.Url); + + bool channelExisted = false; + long fallbackDuringCallback = -1; + TaskCompletionSource callbackDone = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + client.connection.OnConnected += async () => + { + // Stands in for the node answering a subscription issued from this very handler. + channelExisted = client.connection.IsMessageProcessorRunning; + await client.connection.IOnMessageFastPath( + Encoding.UTF8.GetBytes(TransactionMessage), + client.connection.ActiveSessionId); + fallbackDuringCallback = client.connection.FallbackDispatchedStreamMessages; + callbackDone.TrySetResult(); + }; + + await client.Connect(); + + try + { + // Connect() returning is not enough: OnceOpen resolves the waiters before invoking + // this callback, so without the wait the assertions below can read their defaults. + Task finished = await Task.WhenAny(callbackDone.Task, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.AreSame(callbackDone.Task, finished, "the OnConnected handler never ran"); + + Assert.IsTrue(channelExisted, + "the queue did not exist while consumer code was subscribing - frames answered there take the fallback"); + Assert.AreEqual(0L, fallbackDuringCallback, + "a frame answered during OnConnected went round the queue"); + } + finally + { + await client.Disconnect(); + } + } + + /// + /// The queue survives the ping timer starting, which happens after the callback. + /// + /// + /// This is the coupling that blocked the fix: StartPingTimer calls + /// StopPingTimerSync, and that used to stop the message processor too. Starting the + /// processor early is worth nothing if the ping timer tears it down a moment later, and the + /// symptom is invisible - the fallback keeps delivering frames, just without any of the queue's + /// guarantees. + /// + [TestMethod] + public async Task TestUStartingThePingTimerLeavesTheProcessorRunning() + { + using ScriptedResponseServer server = new ScriptedResponseServer(ScriptedReply); + using XrplClient client = new XrplClient( + server.Url, + new XrplClient.ClientOptions { UseCustomPing = true }); + await client.Connect(); + + try + { + // The ping timer starts at the very end of OnceOpen, after Connect() has returned. + // Asserting before it runs would pass whether or not it takes the processor down - + // the test has to wait for the thing that used to break it. + DateTime deadline = DateTime.UtcNow.AddSeconds(5); + while (!client.connection.IsPingTimerRunning && DateTime.UtcNow < deadline) + { + await Task.Delay(10); + } + + Assert.IsTrue(client.connection.IsPingTimerRunning, "the ping timer never started"); + + Assert.IsTrue(client.connection.IsMessageProcessorRunning, + "the ping timer took the message processor down with it"); + + long fallbackBefore = client.connection.FallbackDispatchedStreamMessages; + + TaskCompletionSource received = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + client.OnTransaction += r => + { + received.TrySetResult(r); + return Task.CompletedTask; + }; + + await client.connection.IOnMessageFastPath( + Encoding.UTF8.GetBytes(TransactionMessage), + client.connection.ActiveSessionId); + + Task completed = await Task.WhenAny(received.Task, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.AreSame(received.Task, completed, "the frame never reached the handler"); + + Assert.AreEqual(0L, client.connection.FallbackDispatchedStreamMessages - fallbackBefore, + "the frame took the fallback, so the processor was not running after all"); + } + finally + { + await client.Disconnect(); + } + } +} diff --git a/Tests/Xrpl.Tests/Client/TestUStreamRawJson.cs b/Tests/Xrpl.Tests/Client/TestUStreamRawJson.cs new file mode 100644 index 00000000..6faf43f0 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestUStreamRawJson.cs @@ -0,0 +1,783 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +using Xrpl.Client.Json; +using Xrpl.Models.Methods; +using Xrpl.Models.Subscriptions; + +// The last remainder of the raw-response effort (plans/2026-08-17-raw-response-level*.md all name +// it): the frame reaches a query response's RawResult, but a stream event still went through +// EnqueueStreamMessage(Text()) - already a UTF-16 string with no frame behind it - so +// TransactionStream and friends had nowhere to hang a Raw. These tests cover the frame's trip +// through Connection's stream pipeline (OnMessage -> the byte[] channel -> AttachFrame) rather +// than just the model in isolation, since that pipeline is exactly what regressed twice before. +namespace Xrpl.Tests.ClientLib +{ + [TestClass] + public class TestUStreamRawJson + { + public static SetupUnitClient runner; + + [TestInitialize] + public async Task MyTestInitializeAsync() + { + runner = await new SetupUnitClient().SetupClient(); + } + + [TestCleanup] + public async Task MyTestCleanupAsync() + { + await runner.client.Disconnect(); + } + + /// Carries a field no model on this stream knows, to prove Raw is not reconstructed. + private const string LedgerClosedMessage = """ + { + "type": "ledgerClosed", + "fee_base": 10, + "fee_ref": 10, + "ledger_hash": "B3980C722D71873D6708723E71B7A28C826BC66C58712ADCEC61603415305CD1", + "ledger_index": 66093872, + "ledger_time": 683942720, + "reserve_base": 20000000, + "reserve_inc": 5000000, + "txn_count": 70, + "validated_ledgers": "65201743-66093872", + "network_id": 9999 + } + """; + + private const string TransactionStreamApiV2 = """ + { + "type": "transaction", + "status": "closed", + "validated": true, + "ledger_index": 106400001, + "ledger_hash": "AA11BB22CC33DD44EE55FF66001122334455667788990011223344556677889", + "hash": "FEDCBA9876543210FEDCBA9876543210FEDCBA9876543210FEDCBA987654321", + "engine_result": "tesSUCCESS", + "engine_result_code": 0, + "engine_result_message": "The transaction was applied. Only final in a validated ledger.", + "tx_json": { + "TransactionType": "Payment", + "Account": "rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd", + "Destination": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "Amount": "1000000", + "Fee": "12", + "Sequence": 1 + }, + "meta": { + "AffectedNodes": [], + "TransactionIndex": 3, + "TransactionResult": "tesSUCCESS" + } + } + """; + + private const string TransactionStreamApiV1 = """ + { + "type": "transaction", + "status": "closed", + "validated": true, + "ledger_index": 106400002, + "ledger_hash": "BB22CC33DD44EE55FF660011223344556677889900112233445566778899AA", + "engine_result": "tesSUCCESS", + "engine_result_code": 0, + "engine_result_message": "The transaction was applied. Only final in a validated ledger.", + "transaction": { + "TransactionType": "Payment", + "Account": "rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd", + "Destination": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "Amount": "500000", + "Fee": "10", + "Sequence": 2, + "hash": "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCD" + }, + "meta": { + "AffectedNodes": [], + "TransactionIndex": 4, + "TransactionResult": "tesSUCCESS" + } + } + """; + + /// Extracts a top-level member's source text through an independent path (JsonDocument), to check RawTransaction against. + private static string TopLevelMemberRawText(string message, string name) + { + using JsonDocument document = JsonDocument.Parse(message); + return document.RootElement.GetProperty(name).GetRawText(); + } + + /// + /// The event exactly as the node sent it must survive the trip through OnMessage, + /// the byte[] channel and AttachFrame byte for byte - including a field + /// (network_id) that has no *declared* property for, + /// which is exactly what distinguishes Raw (the literal bytes) from a re-serialization of + /// the typed model (parsed values, round-tripped through ). + /// + /// + /// Before existed, network_id - which + /// NetworkOpsImp::pubLedger (NetworkOPs.cpp) sends unconditionally on every + /// ledgerClosed push - silently vanished from the typed projection entirely: this + /// test used to assert the re-serialization did NOT contain it, pinning the loss as + /// expected behavior. Extension-data capture on the shared stream base fixed that, so the + /// field now survives a full round trip the same way Raw always did - the assertion below + /// was flipped to prove it stays, not that it disappears. + /// + [TestMethod] + public async Task TestLedgerClosedRawSurvivesTheStreamPipelineByteForByte() + { + TaskCompletionSource received = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + runner.client.connection.OnLedgerClosed += r => + { + received.TrySetResult(r); + return Task.CompletedTask; + }; + + await runner.client.connection.OnMessage(LedgerClosedMessage); + + Task completed = await Task.WhenAny(received.Task, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.AreSame(received.Task, completed, "OnLedgerClosed was not invoked within timeout"); + + LedgerStream result = await received.Task; + Assert.AreEqual(ResponseStreamType.ledgerClosed, result.Type); + Assert.AreEqual(LedgerClosedMessage, result.Raw.ToString(), + "Raw must be the exact bytes of the message, not a re-encoded copy"); + + // The member the model has no *declared* property for: present in Raw (the literal + // bytes) and, since UnknownFields captures it, also present in a re-serialization of + // the typed projection - it must not be dropped on the way back out. + StringAssert.Contains(result.Raw.ToString(), "network_id"); + Assert.IsTrue(result.UnknownFields.ContainsKey("network_id"), + "network_id has no declared property on LedgerStream - it must land in UnknownFields instead of vanishing"); + Assert.AreEqual(9999u, result.UnknownFields["network_id"].GetUInt32()); + string reserialized = JsonSerializer.Serialize(result, XrplJsonOptions.Default); + StringAssert.Contains(reserialized, "network_id", + "UnknownFields round-trips on serialization - network_id must survive, not be silently dropped"); + } + + [TestMethod] + public async Task TestTransactionStreamRawTransactionUsesTxJsonUnderApiV2() + { + TaskCompletionSource received = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + runner.client.connection.OnTransaction += r => + { + received.TrySetResult(r); + return Task.CompletedTask; + }; + + await runner.client.connection.OnMessage(TransactionStreamApiV2); + + Task completed = await Task.WhenAny(received.Task, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.AreSame(received.Task, completed, "OnTransaction was not invoked within timeout"); + + TransactionStream result = await received.Task; + Assert.IsFalse(result.RawTransaction.IsEmpty, "API v2 reports the transaction under tx_json"); + Assert.AreEqual( + TopLevelMemberRawText(TransactionStreamApiV2, "tx_json"), + result.RawTransaction.ToString()); + + // Raw is the whole event; RawTransaction is only the transaction inside it - the two + // must not collapse into the same thing, or a wallet asking for "just the tx" would get + // engine_result/meta/etc. along with it. Checked directly, not through + // Assert.AreNotEqual(Raw.ToString(), RawTransaction.ToString()) - that held on any two + // differently-sized strings regardless of what either one actually contained, so it + // would still pass even if RawTransaction picked up the wrong slice entirely. + Assert.AreEqual(TransactionStreamApiV2, result.Raw.ToString()); + StringAssert.Contains(result.Raw.ToString(), "engine_result", + "sanity: the outer event carries fields RawTransaction must not"); + Assert.IsFalse( + result.RawTransaction.ToString().Contains("engine_result", StringComparison.Ordinal), + "RawTransaction must be only the tx_json object, not the event it sits inside"); + } + + private const string TransactionStreamDuplicateTxJson = """ + { + "type": "transaction", + "status": "closed", + "validated": true, + "tx_json": { + "TransactionType": "Payment", + "Account": "rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd", + "Destination": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "Amount": "1000000", + "Fee": "12", + "Sequence": 1 + }, + "engine_result": "tesSUCCESS", + "engine_result_code": 0, + "tx_json": { + "TransactionType": "Payment", + "Account": "rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd", + "Destination": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "Amount": "999999999", + "Fee": "99", + "Sequence": 42 + }, + "meta": { + "AffectedNodes": [], + "TransactionIndex": 3, + "TransactionResult": "tesSUCCESS" + } + } + """; + + /// + /// rippled never sends a duplicate top-level tx_json, but the frame arrives over a + /// network path this library does not control - an intermediate proxy or a compromised + /// link is not prevented from sending one. -based deserializer + /// and must then agree on which occurrence wins, + /// or a wallet showing to a person and then + /// signing would show one transaction and sign + /// a different one. + /// + [TestMethod] + public async Task TestTransactionStreamRawTransactionUsesTheLastOccurrenceOfADuplicateTxJson() + { + TaskCompletionSource received = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + runner.client.connection.OnTransaction += r => + { + received.TrySetResult(r); + return Task.CompletedTask; + }; + + await runner.client.connection.OnMessage(TransactionStreamDuplicateTxJson); + + Task completed = await Task.WhenAny(received.Task, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.AreSame(received.Task, completed, "OnTransaction was not invoked within timeout"); + + TransactionStream result = await received.Task; + + // Sanity check on the typed side first: System.Text.Json's own last-value-wins + // behavior for a duplicate JSON member feeding one POCO property means Transaction + // already reflects the second occurrence (Sequence 42), not the first (Sequence 1). + Assert.AreEqual(42u, result.Transaction.Sequence, "sanity: the typed side must already reflect the second occurrence"); + + string rawTransaction = result.RawTransaction.ToString(); + StringAssert.Contains(rawTransaction, "\"Sequence\": 42"); + // Discriminates on Fee, not Sequence: "Sequence": 1 is the last member of the first + // envelope, so no comma follows it and a search for `"Sequence": 1,` matched nothing + // either way - the assertion passed even when RawTransaction picked the first + // occurrence. Fee differs between the two envelopes and sits mid-object in both. + Assert.IsFalse(rawTransaction.Contains("\"Fee\": \"12\"", StringComparison.Ordinal), + "RawTransaction picked the first occurrence instead of the last - it would show a wallet a different transaction than the one Transaction/signing would use"); + } + + private const string TransactionStreamBothEnvelopes = """ + { + "type": "transaction", + "status": "closed", + "validated": true, + "tx_json": { + "TransactionType": "Payment", + "Account": "rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd", + "Destination": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "Amount": "1000000", + "Fee": "12", + "Sequence": 11 + }, + "engine_result": "tesSUCCESS", + "transaction": { + "TransactionType": "Payment", + "Account": "rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd", + "Destination": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "Amount": "222222222", + "Fee": "22", + "Sequence": 22 + }, + "meta": { + "AffectedNodes": [], + "TransactionIndex": 3, + "TransactionResult": "tesSUCCESS" + } + } + """; + + /// + /// rippled sends one envelope or the other, never both - NetworkOpsImp::transJson + /// moves transaction to tx_json under API v2 rather than adding it. But the + /// frame reaches this library over the network through arbitrary infrastructure, and a + /// wallet showing while signing what + /// holds must never be shown one transaction + /// and sign another. Both views therefore resolve the same envelope: the one later in the + /// document, which is what the typed side's pair of `value ?? _transaction` setters leaves + /// behind after running in document order. + /// + [TestMethod] + public async Task TestTransactionStreamRawTransactionAgreesWithTypedWhenBothEnvelopesArePresent() + { + TaskCompletionSource received = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + runner.client.connection.OnTransaction += r => + { + received.TrySetResult(r); + return Task.CompletedTask; + }; + + await runner.client.connection.OnMessage(TransactionStreamBothEnvelopes); + + Task completed = await Task.WhenAny(received.Task, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.AreSame(received.Task, completed, "OnTransaction was not invoked within timeout"); + + TransactionStream result = await received.Task; + + // Sanity check on the typed side first: "transaction" sits after "tx_json" here, so the + // second setter to run wins and Transaction holds Sequence 22. + Assert.AreEqual(22u, result.Transaction.Sequence, "sanity: the typed side must reflect the envelope that appears later"); + + string rawTransaction = result.RawTransaction.ToString(); + StringAssert.Contains(rawTransaction, "\"Sequence\": 22"); + Assert.IsFalse(rawTransaction.Contains("\"Sequence\": 11", StringComparison.Ordinal), + "RawTransaction resolved tx_json while the typed Transaction resolved the later \"transaction\" envelope - a wallet would display one transaction and sign another"); + } + + private const string TransactionStreamNullLegacyEnvelope = """ + { + "type": "transaction", + "status": "closed", + "validated": true, + "tx_json": { + "TransactionType": "Payment", + "Account": "rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd", + "Destination": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "Amount": "1000000", + "Fee": "12", + "Sequence": 33 + }, + "engine_result": "tesSUCCESS", + "transaction": null, + "meta": { + "AffectedNodes": [], + "TransactionIndex": 3, + "TransactionResult": "tesSUCCESS" + } + } + """; + + /// + /// An envelope explicitly set to JSON null is not an envelope. The typed setters + /// discard it (value ?? _transaction), so the slice must too — otherwise the later + /// position of a null transaction would win the tie-break and hand + /// four bytes of literal while + /// held the real payment. + /// + [TestMethod] + public async Task TestTransactionStreamIgnoresAnEnvelopeExplicitlySetToNull() + { + TaskCompletionSource received = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + runner.client.connection.OnTransaction += r => + { + received.TrySetResult(r); + return Task.CompletedTask; + }; + + await runner.client.connection.OnMessage(TransactionStreamNullLegacyEnvelope); + + Task completed = await Task.WhenAny(received.Task, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.AreSame(received.Task, completed, "OnTransaction was not invoked within timeout"); + + TransactionStream result = await received.Task; + + Assert.AreEqual(33u, result.Transaction.Sequence, "sanity: the typed side ignores the null envelope"); + + string rawTransaction = result.RawTransaction.ToString(); + Assert.AreNotEqual("null", rawTransaction, + "RawTransaction took the null envelope while Transaction took the real one - the two views must not disagree"); + StringAssert.Contains(rawTransaction, "\"Sequence\": 33"); + } + + /// + /// An event that arrived without a type must not report one. The property is + /// nullable precisely so absence round-trips as absence rather than as + /// ResponseStreamType.UNKNOWN, the enum's zero value — the same fabrication this + /// branch removes everywhere else. Was untested: reverting the property to non-nullable + /// left the whole suite green. + /// + [TestMethod] + public void TestStreamEventWithoutATypeDoesNotInventOne() + { + TransactionStream blank = new TransactionStream(); + Assert.IsNull(blank.Type, "no message assigned a type - the property must stay null, not read as UNKNOWN"); + + string serialized = JsonSerializer.Serialize(blank, XrplJsonOptions.Default); + Assert.IsFalse(serialized.Contains("\"type\"", StringComparison.Ordinal), + "re-serializing an event that carried no type must not emit one: " + serialized); + + TransactionStream parsed = JsonSerializer.Deserialize( + "{\"status\":\"closed\",\"validated\":true}", XrplJsonOptions.Default); + Assert.IsNull(parsed.Type, "the message carried no type member - the property must stay null"); + } + + private const string TransactionStreamDuplicateEndingInNull = """ + { + "type": "transaction", + "status": "closed", + "validated": true, + "tx_json": { + "TransactionType": "Payment", + "Account": "rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd", + "Destination": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "Amount": "1000000", + "Fee": "12", + "Sequence": 55 + }, + "engine_result": "tesSUCCESS", + "tx_json": null, + "meta": { + "AffectedNodes": [], + "TransactionIndex": 3, + "TransactionResult": "tesSUCCESS" + } + } + """; + + /// + /// The last-occurrence rule skips null-valued occurrences, because the typed setters do: + /// they run `value ?? _transaction`, so a duplicate ending in null leaves the real object + /// in place. Resolving the slice to that null instead would empty + /// while + /// still held a payment — showing a wallet + /// nothing while it signs something. + /// + [TestMethod] + public async Task TestTransactionStreamSkipsANullOccurrenceWhenPickingTheLastOne() + { + TaskCompletionSource received = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + runner.client.connection.OnTransaction += r => + { + received.TrySetResult(r); + return Task.CompletedTask; + }; + + await runner.client.connection.OnMessage(TransactionStreamDuplicateEndingInNull); + + Task completed = await Task.WhenAny(received.Task, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.AreSame(received.Task, completed, "OnTransaction was not invoked within timeout"); + + TransactionStream result = await received.Task; + + Assert.AreEqual(55u, result.Transaction.Sequence, "sanity: the typed side keeps the object, discarding the null duplicate"); + + Assert.IsFalse(result.RawTransaction.IsEmpty, + "RawTransaction resolved to the trailing null while Transaction kept the payment - the two views must not disagree"); + StringAssert.Contains(result.RawTransaction.ToString(), "\"Sequence\": 55"); + } + + // rippled NetworkOpsImp: account_history_tx_index is written on every event of such a + // subscription (forwardTxIndex++ streaming forward, txHistoryIndex-- backfilling, hence + // the negative value here); account_history_boundary marks the last transaction of a + // ledger and account_history_tx_first the earliest transaction the account ever had, so + // those two appear only on some events. All three are declared properties rather than + // extension-data captures because capture costs ~464 B per member. + private const string AccountHistoryTransaction = """ + { + "type": "transaction", + "status": "closed", + "validated": true, + "engine_result": "tesSUCCESS", + "account_history_tx_index": -5, + "account_history_boundary": true, + "account_history_tx_first": true, + "tx_json": { + "TransactionType": "Payment", + "Account": "rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd", + "Sequence": 7 + }, + "meta": { + "AffectedNodes": [], + "TransactionIndex": 3, + "TransactionResult": "tesSUCCESS" + } + } + """; + + /// + /// The account_history_* members reach declared properties, not + /// — a typo in any of the three + /// [JsonPropertyName] attributes would silently route the field back into capture, + /// which round-trips either way and so shows up in no fidelity check. + /// + [TestMethod] + public void TestAccountHistoryMembersReachDeclaredProperties() + { + TransactionStream result = JsonSerializer.Deserialize( + AccountHistoryTransaction, XrplJsonOptions.Default); + + Assert.AreEqual(-5L, result.AccountHistoryTxIndex, "backfill counts down through zero, so the index must survive as a signed value"); + Assert.AreEqual(true, result.AccountHistoryBoundary); + Assert.AreEqual(true, result.AccountHistoryTxFirst); + + Assert.IsTrue(result.UnknownFields is null || result.UnknownFields.Count == 0, + "all three are declared properties - none of them should have fallen into capture: " + + (result.UnknownFields is null ? "" : string.Join(", ", result.UnknownFields.Keys))); + } + + /// + /// An event of an ordinary transactions subscription carries none of them, and must + /// not report values the node never sent. + /// + [TestMethod] + public void TestAccountHistoryMembersStayNullWhenNotAnAccountHistorySubscription() + { + TransactionStream result = JsonSerializer.Deserialize( + TransactionStreamApiV2, XrplJsonOptions.Default); + + Assert.IsNull(result.AccountHistoryTxIndex); + Assert.IsNull(result.AccountHistoryBoundary); + Assert.IsNull(result.AccountHistoryTxFirst); + } + + private const string TransactionStreamUppercaseTxJson = """ + { + "type": "transaction", + "status": "closed", + "validated": true, + "engine_result": "tesSUCCESS", + "TX_JSON": { + "TransactionType": "Payment", + "Account": "rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd", + "Destination": "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + "Amount": "1000000", + "Fee": "12", + "Sequence": 7 + }, + "meta": { + "AffectedNodes": [], + "TransactionIndex": 3, + "TransactionResult": "tesSUCCESS" + } + } + """; + + /// + /// sets + /// , so the + /// deserializer binds a differently-cased "TX_JSON" to + /// same as it would "tx_json". + /// has to match the same way, or + /// would come back empty on exactly the + /// frame whose typed is populated. + /// + [TestMethod] + public async Task TestTransactionStreamRawTransactionMatchesTxJsonCaseInsensitively() + { + TaskCompletionSource received = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + runner.client.connection.OnTransaction += r => + { + received.TrySetResult(r); + return Task.CompletedTask; + }; + + await runner.client.connection.OnMessage(TransactionStreamUppercaseTxJson); + + Task completed = await Task.WhenAny(received.Task, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.AreSame(received.Task, completed, "OnTransaction was not invoked within timeout"); + + TransactionStream result = await received.Task; + + // Sanity check first: confirms the fixture actually exercises case-insensitive binding + // rather than accidentally matching some other way. + Assert.IsNotNull(result.Transaction, "sanity: the typed side must bind \"TX_JSON\" case-insensitively"); + Assert.AreEqual(7u, result.Transaction.Sequence); + + Assert.IsFalse(result.RawTransaction.IsEmpty, + "FindTopLevelMember must match \"TX_JSON\" case-insensitively, the same way the deserializer bound it to Transaction"); + StringAssert.Contains(result.RawTransaction.ToString(), "\"Sequence\": 7"); + } + + [TestMethod] + public async Task TestTransactionStreamRawTransactionUsesTransactionUnderApiV1() + { + TaskCompletionSource received = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + runner.client.connection.OnTransaction += r => + { + received.TrySetResult(r); + return Task.CompletedTask; + }; + + await runner.client.connection.OnMessage(TransactionStreamApiV1); + + Task completed = await Task.WhenAny(received.Task, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.AreSame(received.Task, completed, "OnTransaction was not invoked within timeout"); + + TransactionStream result = await received.Task; + Assert.IsFalse(result.RawTransaction.IsEmpty, "API v1 reports the transaction under transaction"); + Assert.AreEqual( + TopLevelMemberRawText(TransactionStreamApiV1, "transaction"), + result.RawTransaction.ToString()); + } + + /// + /// A stream message carrying neither envelope - the same input + /// TestUTransactionStreamEnvelope.TestTransactionStreamWithoutEnvelopeDoesNotThrow + /// pins for the typed property - must read as no + /// raw transaction either, not throw and not alias some unrelated member. + /// + [TestMethod] + public void TestRawTransactionIsEmptyWithoutEitherEnvelope() + { + const string message = """ + {"type":"transaction","status":"closed","validated":true,"engine_result":"tesSUCCESS"} + """; + + byte[] frame = Encoding.UTF8.GetBytes(message); + TransactionStream stream = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); + stream.AttachFrame(frame); + + Assert.IsTrue(stream.RawTransaction.IsEmpty); + Assert.IsFalse(stream.Raw.IsEmpty, "the event itself was still parsed off a real frame"); + } + + /// Mirrors TestUAttachFrameRejectsANullFrame for the stream side of the frame. + [TestMethod] + public void TestAttachFrameRejectsANullFrame() + { + Assert.ThrowsExactly(() => new LedgerStream().AttachFrame(null)); + Assert.ThrowsExactly(() => new TransactionStream().AttachFrame(null)); + } + + /// Raw/RawTransaction on an event nobody paired with a frame - built by hand, or deserialized outside the stream pipeline - must read as empty, not throw. + [TestMethod] + public void TestRawIsEmptyWithoutAnAttachedFrame() + { + TransactionStream stream = JsonSerializer.Deserialize(TransactionStreamApiV2, XrplJsonOptions.Default); + + Assert.IsTrue(stream.Raw.IsEmpty); + Assert.IsTrue(stream.RawTransaction.IsEmpty); + } + + /// + /// All the tests above drive OnMessage(string), where Frame() always + /// synthesizes a fresh byte array from the string via Encoding.UTF8.GetBytes - the + /// utf8Message ?? branch of Frame(), which is what + /// ws.OnBinaryMessage actually feeds in production and the entire reason the + /// stream pipeline was moved onto bytes in the first place, is never exercised by any of + /// them. This drives directly - the + /// same overload the socket callback calls - with a frame the test owns, and checks that + /// the frame reaching / + /// is that literal array, not a re-encoded copy of it. + /// + [TestMethod] + public async Task TestBinaryFramePathRetainsTheSameArrayNotACopy() + { + TaskCompletionSource received = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + runner.client.connection.OnTransaction += r => + { + received.TrySetResult(r); + return Task.CompletedTask; + }; + + byte[] frame = Encoding.UTF8.GetBytes(TransactionStreamApiV2); + + await runner.client.connection.IOnMessageFastPath(frame); + + Task completed = await Task.WhenAny(received.Task, Task.Delay(TimeSpan.FromSeconds(5))); + Assert.AreSame(received.Task, completed, "OnTransaction was not invoked within timeout"); + + TransactionStream result = await received.Task; + + // _frame is internal on BaseStream specifically so a test can check this directly, + // rather than inferring aliasing indirectly (e.g. by mutating the source array and + // checking whether Raw sees the change) the way TestURawJsonToJsonElementOwnsItsData + // proves the opposite (copying) contract for JsonElement. + Assert.AreSame(frame, result._frame, + "the byte[] entry point must retain the very same array the socket handed over, not a copy of it"); + Assert.AreEqual(TransactionStreamApiV2, result.Raw.ToString()); + Assert.AreEqual( + TopLevelMemberRawText(TransactionStreamApiV2, "tx_json"), + result.RawTransaction.ToString()); + } + + /// + /// The frame the byte[] channel carries is shared, not copied per event: pairing + /// with it through + /// must add no more than a couple of reference fields per instance on top of what + /// deserializing the event already costs - not a second copy of the frame (900+ B here), + /// which is the shape the two prior retention regressions on this branch took. Mirrors + /// TestUEnvelopeRetainsNoMoreThanTheFrame, applied to the stream side of the frame. + /// + [TestMethod] + [DoNotParallelize] + public void TestUTransactionStreamAttachFrameRetainsNoMoreThanTheFrame() + { + const int Count = 2000; + byte[] frame = Encoding.UTF8.GetBytes(TransactionStreamApiV2); + + // Warm up JIT and type-init for both calls outside any measured window. + TransactionStream warm = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); + warm.AttachFrame(frame); + + // The instances exist before the window opens, and the window contains nothing but the + // AttachFrame calls. That is the whole of the fix to how this used to be measured: it + // took two whole-heap readings in two separate windows - one pass that attached, one + // that did not - and subtracted them. Anything that moved the heap between those two + // passes landed in the answer, and on Linux something did, by more than a megabyte: + // the unattached pass read ~600 B/instance lower than the attached one and the + // difference was reported as AttachFrame's cost. It failed CI twice, on two unrelated + // pull requests, and took a merge down with it; locally it read 0 B every time. + // + // Attaching to instances that already exist removes the comparison instead of widening + // the bound: what the window measures is exactly what AttachFrame allocates, which is + // the thing the test is named after. + List retained = new List(Count); + for (int i = 0; i < Count; i++) + { + retained.Add(JsonSerializer.Deserialize(frame, XrplJsonOptions.Default)); + } + + GC.Collect(2, GCCollectionMode.Forced, blocking: true); + GC.WaitForPendingFinalizers(); + GC.Collect(2, GCCollectionMode.Forced, blocking: true); + long before = GC.GetTotalMemory(true); + + foreach (TransactionStream stream in retained) + { + stream.AttachFrame(frame); + } + + GC.Collect(2, GCCollectionMode.Forced, blocking: true); + GC.WaitForPendingFinalizers(); + GC.Collect(2, GCCollectionMode.Forced, blocking: true); + long marginal = (GC.GetTotalMemory(true) - before) / Count; + + GC.KeepAlive(retained); + GC.KeepAlive(warm); + + Console.WriteLine( + $"AttachFrame adds {marginal} B/instance across {Count} instances " + + $"(frame is {frame.Length} B, shared by all of them)"); + + // A frame copied per instance - the failure mode this guards against, and the shape of + // the two retention regressions on this branch - would add close to frame.Length here, + // which is 744 B and 1.5 MB over the sample. Storing one reference and two int pairs + // adds nothing measurable, so the bound has two and a half times the headroom it needs + // over any jitter and still misses a real copy by a wide margin. + Assert.IsTrue( + marginal < 300, + $"AttachFrame added {marginal} B/instance; budget is 300 B, and a full copy of the " + + $"{frame.Length} B frame would show up as {frame.Length}+"); + } + } +} diff --git a/Tests/Xrpl.Tests/Client/TestUXrplResponse.cs b/Tests/Xrpl.Tests/Client/TestUXrplResponse.cs new file mode 100644 index 00000000..5744acaa --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestUXrplResponse.cs @@ -0,0 +1,263 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System.Collections.Generic; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +using Xrpl.Client; +using Xrpl.Client.Exceptions; +using Xrpl.Client.Json; +using Xrpl.Models.Ledger; +using Xrpl.Models.Subscriptions; +using Xrpl.Models.Methods; +using Xrpl.Tests; + +namespace XrplTests.Client; + +/// +/// The envelope a caller gets back: the typed projection and, beside it, the bytes the node sent. +/// The point of the pair is that the projection cannot be mistaken for the source — it has been +/// through a parse and re-emit, so member order, number formatting and whitespace are the +/// serializer's, not the node's. It no longer drops what the model does not declare (extension-data +/// capture keeps those), but only Raw is what actually arrived on the wire. +/// +[TestClass] +public class TestUXrplResponse +{ + [TestMethod] + public void TestUCarriesResultAndRawSideBySide() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"result\":{\"ledger_index\":9,\"marker\":\"AABB\"}}"); + RawJson raw = new RawJson(frame, 10, frame.Length - 11); + LOLedgerData typed = raw.Deserialize(); + + XrplResponse response = new XrplResponse(typed, raw, 2, null, null, null, false); + + Assert.AreSame(typed, response.Result); + Assert.AreEqual("{\"ledger_index\":9,\"marker\":\"AABB\"}", response.Raw.ToString()); + Assert.AreEqual(2u, response.ApiVersion); + } + + [TestMethod] + public void TestUWarningsAreNeverNull() + { + XrplResponse response = new XrplResponse(null, default, null, null, null, null, false); + + Assert.IsNotNull(response.Warnings); + Assert.AreEqual(0, response.Warnings.Count); + } + + /// + /// var (result, raw) = response must hand back exactly what + /// and would — this is the one-line escape hatch for the + /// var call sites that otherwise have to be restructured for the new return type. + /// + [TestMethod] + public void TestUDeconstructsIntoResultAndRaw() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"result\":{\"ledger_index\":9,\"marker\":\"AABB\"}}"); + RawJson raw = new RawJson(frame, 10, frame.Length - 11); + LOLedgerData typed = raw.Deserialize(); + + XrplResponse response = new XrplResponse(typed, raw, 2, null, null, null, false); + + var (result, deconstructedRaw) = response; + + Assert.AreSame(typed, result); + Assert.AreEqual("{\"ledger_index\":9,\"marker\":\"AABB\"}", deconstructedRaw.ToString()); + } + + /// + /// The wrapper's own paging signal, read off rather than a + /// parsed projection — the same rule BaseResponse.HasNextPage follows, now reachable from + /// the type a caller of the client's own methods actually holds. + /// + [TestMethod] + public void TestUHasNextPageReflectsTheMarker() + { + byte[] withMarker = Encoding.UTF8.GetBytes("{\"ledger_index\":9,\"marker\":\"AABB\"}"); + XrplResponse paged = new XrplResponse( + null, new RawJson(withMarker, 0, withMarker.Length), null, null, null, null, false); + Assert.IsTrue(paged.HasNextPage); + + byte[] withoutMarker = Encoding.UTF8.GetBytes("{\"ledger_index\":9}"); + XrplResponse lastPage = new XrplResponse( + null, new RawJson(withoutMarker, 0, withoutMarker.Length), null, null, null, null, false); + Assert.IsFalse(lastPage.HasNextPage); + } + + /// + /// The whole point of the feature, end to end over a real socket: what the node sent reaches + /// the caller unchanged. The scripted body carries irregular whitespace and a member no model + /// knows, so this fails if anything on the path normalizes or reprojects the bytes. + /// + [TestMethod] + public async Task TestURawSurvivesTheTripFromTheSocket() + { + const string Result = "{\"ledger_current_index\" : 96000000,\"a_field_no_model_knows\":[1, 2]}"; + + using ScriptedResponseServer server = new ScriptedResponseServer( + "{\"id\":__ID__,\"status\":\"success\",\"type\":\"response\",\"api_version\":2," + + "\"warning\":\"load\",\"forwarded\":true,\"result\":" + Result + "}"); + using XrplClient client = new XrplClient(server.Url, new XrplClient.ClientOptions { ApiVersion = 2 }); + await client.Connect(); + + XrplResponse response = + await client.LedgerCurrent(new LedgerCurrentRequest()); + + // Byte for byte, whitespace included. + Assert.AreEqual(Result, response.Raw.ToString()); + + // The member the model has no declared property for reaches the caller twice over: as the + // literal bytes in Raw, and — since extension-data capture was extended to every response + // projection — as a parsed value on the typed side too, rather than being dropped. This + // assertion used to be its mirror image, pinning the loss as expected behavior. + StringAssert.Contains(response.Raw.ToString(), "a_field_no_model_knows"); + + string projection = JsonSerializer.Serialize(response.Result, XrplJsonOptions.Default); + StringAssert.Contains(projection, "ledger_current_index"); + StringAssert.Contains(projection, "a_field_no_model_knows"); + Assert.IsNotNull(response.Result.UnknownFields); + Assert.IsTrue(response.Result.UnknownFields.ContainsKey("a_field_no_model_knows"), + "a member the model does not declare must land in UnknownFields, not vanish"); + + // Raw still earns its place: it is the exact bytes, whitespace included, while the + // projection above has been through a parse and re-emit. + Assert.AreNotEqual(Result, projection); + + Assert.AreEqual(96000000u, response.Result.CurrentIndex); + + await client.Disconnect(); + } + + /// + /// The envelope the client used to unwrap and discard. `warning` in particular was unreachable: + /// it is not part of `result`, so the raw bytes do not carry it either. + /// + [TestMethod] + public async Task TestUEnvelopeReachesTheCaller() + { + using ScriptedResponseServer server = new ScriptedResponseServer( + "{\"id\":__ID__,\"status\":\"success\",\"type\":\"response\",\"api_version\":2," + + "\"warning\":\"load\",\"forwarded\":true," + + "\"warnings\":[{\"id\":1004,\"message\":\"This is a reporting server\"}]," + + "\"result\":{\"ledger_current_index\":9}}"); + using XrplClient client = new XrplClient(server.Url, new XrplClient.ClientOptions { ApiVersion = 2 }); + await client.Connect(); + + XrplResponse response = + await client.LedgerCurrent(new LedgerCurrentRequest()); + + Assert.AreEqual("load", response.Warning); + Assert.AreEqual(2u, response.ApiVersion); + Assert.IsTrue(response.Forwarded); + Assert.AreEqual(1, response.Warnings.Count); + Assert.AreEqual(1004u, response.Warnings[0].Id); + + await client.Disconnect(); + } + + /// + /// status sits beside result in the envelope, the same as warning — before + /// existed, + /// read every other envelope member off BaseResponse except this one, so it was + /// unreachable from a caller holding only the typed response: not part of Result (no + /// model declares it) and not part of either (that is a slice + /// of result alone). Value matches a real mainnet account_info response. + /// + [TestMethod] + public async Task TestUStatusReachesTheCaller() + { + using ScriptedResponseServer server = new ScriptedResponseServer( + "{\"id\":__ID__,\"status\":\"success\",\"type\":\"response\"," + + "\"result\":{\"ledger_current_index\":106359163}}"); + using XrplClient client = new XrplClient(server.Url, new XrplClient.ClientOptions { ApiVersion = 2 }); + await client.Connect(); + + XrplResponse response = + await client.LedgerCurrent(new LedgerCurrentRequest()); + + Assert.AreEqual("success", response.Status); + + await client.Disconnect(); + } + + /// The untyped path carries the same envelope and the same raw bytes. + [TestMethod] + public async Task TestUUntypedRequestAlsoCarriesRawAndEnvelope() + { + const string Result = "{\"ledger_current_index\" : 42,\"unknown\":true}"; + + using ScriptedResponseServer server = new ScriptedResponseServer( + "{\"id\":__ID__,\"status\":\"success\",\"type\":\"response\",\"warning\":\"load\"," + + "\"result\":" + Result + "}"); + using XrplClient client = new XrplClient(server.Url, new XrplClient.ClientOptions { ApiVersion = 2 }); + await client.Connect(); + + XrplResponse> response = await client.Request( + new Dictionary { ["command"] = "ledger_current" }); + + Assert.AreEqual(Result, response.Raw.ToString()); + Assert.AreEqual("load", response.Warning); + Assert.IsTrue(response.Result.ContainsKey("unknown")); + + await client.Disconnect(); + } + + + /// + /// Envelope models must stay serializable. The slice members are set-only for exactly this + /// reason: they carry bounds, not bytes, and the converter refuses to write them — an envelope + /// rebuilt from bounds would be a different document. If they ever regain a getter, System.Text.Json + /// asks for one on write and every envelope throws, including the public subscription types a + /// consumer may well be logging. + /// + [TestMethod] + public void TestUEnvelopeModelsStaySerializable() + { + Assert.AreEqual("{}", JsonSerializer.Serialize(new BaseResponse(), XrplJsonOptions.Default)); + Assert.AreEqual("{}", JsonSerializer.Serialize(new ErrorResponse(), XrplJsonOptions.Default)); + + // Asserted on a value that is set, not on whichever member happens to still be + // non-nullable: every field subLedger can omit is nullable now, so a blank instance + // correctly writes nothing at all. Pinning the check to a set value keeps it proving what + // it is here to prove - that ordinary members still serialize - without breaking again the + // next time a field is correctly made nullable. + Assert.AreEqual("{}", JsonSerializer.Serialize(new LedgerStreamResponse(), XrplJsonOptions.Default)); + + string stream = JsonSerializer.Serialize(new LedgerStreamResponse { FeeBase = 10 }, XrplJsonOptions.Default); + StringAssert.Contains(stream, "fee_base"); + } + + /// + /// A carrying a Result of the wrong type used to throw a + /// bare from the unchecked (T) cast — unlike + /// the sibling overload a few lines up, which raises + /// a typed for its own kind of mismatch. Both overloads now fail + /// the same way. + /// + [TestMethod] + public void TestUFromResolvedResponseRejectsAMismatchedResultType() + { + ResolvedResponse resolved = new ResolvedResponse("not a LOLedgerData", null); + + XrplException raised = Assert.ThrowsExactly( + () => XrplResponse.From(resolved)); + + StringAssert.Contains(raised.Message, "String"); + StringAssert.Contains(raised.Message, nameof(LOLedgerData)); + } + + /// A null Result is a legitimate value for a reference-typed T, not a mismatch. + [TestMethod] + public void TestUFromResolvedResponseAcceptsANullResultForAReferenceType() + { + ResolvedResponse resolved = new ResolvedResponse(null, null); + + XrplResponse response = XrplResponse.From(resolved); + + Assert.IsNull(response.Result); + } + +} diff --git a/Tests/Xrpl.Tests/CreateMockRippled.cs b/Tests/Xrpl.Tests/CreateMockRippled.cs index fddc44a4..3b7653cb 100644 --- a/Tests/Xrpl.Tests/CreateMockRippled.cs +++ b/Tests/Xrpl.Tests/CreateMockRippled.cs @@ -7,6 +7,7 @@ using System.Text; using System.Text.RegularExpressions; using System.Threading; +using System.Threading.Tasks; using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.VisualStudio.TestPlatform.CommunicationUtilities; @@ -133,6 +134,25 @@ public void AddResponse(string command, Dictionary response) _responses[command] = response; } + /// + /// How long to sit on a command's answer before sending it, per command. + /// + private Dictionary _responseDelays = new Dictionary(); + + /// + /// Registers an answer that is sent only after . + /// + /// + /// For tests that need a request to still be in flight while something else happens to the + /// connection. Answered at once, that window is reachable only by luck - which is how the + /// race in issue #122 stayed a CI-only flake through 37 local runs. + /// + public void AddDelayedResponse(string command, Dictionary response, TimeSpan delay) + { + AddResponse(command, response); + _responseDelays[command] = delay; + } + Dictionary GetResponse(Dictionary request) { string command = request["command"]?.ToString(); @@ -303,7 +323,31 @@ public void Start() } else if (this._responses.ContainsKey(commandStr)) { - this.Send(e.GetClient(), this.CreateResponse(request, this.GetResponse(request))); + string answer = this.CreateResponse(request, this.GetResponse(request)); + MockClient answerTo = e.GetClient(); + if (this._responseDelays.TryGetValue(commandStr, out TimeSpan delay)) + { + // Answered late, on a task of its own: the read loop has to keep + // running, or nothing else on this connection would happen while the + // answer is held back. + _ = Task.Run(async () => + { + await Task.Delay(delay); + try + { + this.Send(answerTo, answer); + } + catch + { + // The socket may be gone by now - that is usually the point of + // the delay, and it is not this mock's business to complain. + } + }); + } + else + { + this.Send(answerTo, answer); + } } else { diff --git a/Tests/Xrpl.Tests/Fixtures/Responses/README.md b/Tests/Xrpl.Tests/Fixtures/Responses/README.md new file mode 100644 index 00000000..1fa26241 --- /dev/null +++ b/Tests/Xrpl.Tests/Fixtures/Responses/README.md @@ -0,0 +1,66 @@ +# Live mainnet response corpus + +Seven JSON-RPC responses captured from mainnet (`https://xrplcluster.com`), used by +`TestUResponseFidelity` (`Tests/Xrpl.Tests/Models/TestUResponseFidelity.cs`) to guard the +round-trip accuracy this SDK reached at levels 0–3: zero fabricated members, and every +dropped member accounted for by name and reason. + +## Why files, not code + +Earlier the same check was run by hand with throwaway console projects, each deleted after +the measurement. That made the result — 156 fabricated members on a ten-transaction +`account_tx`, now zero — a one-time fact instead of a standing guarantee. A file corpus +turns the manual diff into something CI runs on every push. + +## Provenance + +- **Snapshot date:** 2026-08-17 (six files), 2026-08-18 (`tx_v1_raw.json`) +- **Node:** `https://xrplcluster.com` +- **Transport:** HTTP JSON-RPC (`POST /`, `Content-Type: application/json`) +- **`api_version`:** 2 for every request, except `tx_v1_raw.json` which is deliberately `1` + (see its row below) +- **Envelope:** each file is the full HTTP response body — `{"result": {...}}` — exactly as + the node returned it, with no reformatting or field removal. + +This is why `status` sits *inside* `result` in every file here: that is where HTTP JSON-RPC +puts it. The WebSocket envelope `XrplClient` normally talks to carries `status` as a sibling +of `result` instead, surfaced through `XrplResponse.Status` — so a fidelity check driven +by these files must not expect the model to carry `status` at all (see the exceptions table +in `TestUResponseFidelity`). `TransactionResponse`/`BaseTransactionResponse`, the model +`tx_v1_raw.json` deserializes into, is the one exception: it carries a +`[JsonExtensionData]` catch-all, so `status` round-trips there without needing a +`KnownLostMembers` entry — see that file's row below. + +## Files + +| File | Request | Why this response | +|---|---|---| +| `tx_raw.json` | `tx`, hash `E08D6E9754...`, `api_version: 2` | A `Payment` with `DeliverMax`, an issued-currency amount, a multi-path `Paths` array, and metadata with `PreviousFields`/`FinalFields` on both an `AccountRoot` and a `RippleState` — the exact shape the API v1→v2 `Amount`/`DeliverMax` rename (level 3) targets. | +| `tx_binary_raw.json` | Same transaction, `binary: true` | Same transaction as `tx_raw.json`, but rippled's binary-mode envelope: `meta_blob`/`tx_blob` hex strings replace `meta`/`tx_json` entirely. Exercises the sibling-field branch `TransactionSummary` reads separately from the JSON-mode fields. | +| `tx_v1_raw.json` | Same transaction, `api_version: 1` | The gap level 3's fix missed: on `api_version: 1`, rippled sends **both** `Amount` and `DeliverMax` for this transaction, not just `Amount`. Level 3's single `_amountReceivedAsDeliverMax` bool could only remember whichever of the two field setters ran last, so the other one silently vanished on round-trip. Deserializes as `TransactionResponse` (`PaymentResponse` once `TransactionResponseConverter` dispatches on `TransactionType`), the model behind `IXrplClient.TxV1` — not `TransactionSummary`, which only ever serves `TxV2`/`account_tx`. Added to catch exactly this: a v1 corpus made of `TransactionSummary` files alone can't, because `api_version: 2` never sends both names for one payment. | +| `account_tx_raw.json` | `account_tx`, 10 results, `api_version: 2` | The richest file (36 KB): ten transactions mixing `EscrowCreate`, `EscrowCancel` and `Payment`, with `ModifiedNode`/`CreatedNode`/`DeletedNode` entries touching `AccountRoot`, `RippleState`, `Escrow` and `DirectoryNode`, plus `Memos`, `Condition`, `CancelAfter`/`FinishAfter`. This is the file the original 156-member count was measured against. | +| `account_info_raw.json` | `account_info` | `account_data` (`AccountRoot`) plus the full `account_flags` object — every named account flag in one response. | +| `account_objects_raw.json` | `account_objects` | Ten `RippleState` objects, paginated (`marker` present) and returned with `warning: "load"` — the one other exception besides `status` (see below). | +| `ledger_raw.json` | `ledger` (headers only, no `transactions`/`accounts` expansion) | The ledger header shape (`LOLedger`/`LedgerEntity`) on its own, without transaction or account-state payloads mixed in. | + +## Known, accepted gaps + +`TestUResponseFidelity` enforces **zero** fabricated (added) members — no exceptions, ever. +Dropped members are checked against an explicit, reasoned exception list inside the test +itself; anything not on that list fails the build. Do not duplicate that list here — it +would drift. Read `KnownLostMembers` in `TestUResponseFidelity.cs` for the current, accurate +set. + +## Updating this corpus + +Swapping or adding a file here is a deliberate action, not routine refresh — these fixtures +exist to catch a *model* regression, not to track mainnet drift (see +`plans/2026-08-17-raw-response-level4.md`, "Что этот уровень сознательно не делает"). Before +replacing a file: + +1. Re-run `TestUResponseFidelity` against the new capture and re-derive the exception list — + do not carry the old list forward unreviewed. +2. Update the file's row in the table above with what changed and why the new capture is + needed. +3. Keep `api_version: 2` and the raw, unedited HTTP JSON-RPC envelope unless the very point + of the new fixture is to test a different envelope shape (state that explicitly if so). diff --git a/Tests/Xrpl.Tests/Fixtures/Responses/account_info_raw.json b/Tests/Xrpl.Tests/Fixtures/Responses/account_info_raw.json new file mode 100644 index 00000000..71a4ce73 --- /dev/null +++ b/Tests/Xrpl.Tests/Fixtures/Responses/account_info_raw.json @@ -0,0 +1,2 @@ +{"result":{"account_data":{"Account":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","Balance":"56774125592","Flags":1703936,"LedgerEntryType":"AccountRoot","OwnerCount":1,"PreviousTxnID":"EED332D31E575CEF52DFE837C33B3043ABECAD4669C32BC5BA2805A265D034EB","PreviousTxnLgrSeq":106348355,"RegularKey":"rBmVUQNF6tJy4cLvoKdPXb4BNqKBk5JY1Y","Sequence":44196,"TransferRate":1220000000,"index":"2B6AC232AA4C4BE41BF49D2459FA4A0347E1B543A4C92FCEE0821C0201E2E9A8"},"account_flags":{"allowTrustLineClawback":false,"allowTrustLineLocking":false,"defaultRipple":false,"depositAuth":false,"disableMasterKey":true,"disallowIncomingCheck":false,"disallowIncomingNFTokenOffer":false,"disallowIncomingPayChan":false,"disallowIncomingTrustline":false,"disallowIncomingXRP":true,"globalFreeze":false,"noFreeze":false,"passwordSpent":false,"requireAuthorization":false,"requireDestinationTag":true},"ledger_current_index":106359163,"status":"success","validated":false}} + diff --git a/Tests/Xrpl.Tests/Fixtures/Responses/account_objects_raw.json b/Tests/Xrpl.Tests/Fixtures/Responses/account_objects_raw.json new file mode 100644 index 00000000..b46954f1 --- /dev/null +++ b/Tests/Xrpl.Tests/Fixtures/Responses/account_objects_raw.json @@ -0,0 +1,2 @@ +{"result":{"account":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","account_objects":[{"Balance":{"currency":"USD","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"0.1165139515638055"},"Flags":65536,"HighLimit":{"currency":"USD","issuer":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","value":"0"},"HighNode":"0","LedgerEntryType":"RippleState","LowLimit":{"currency":"USD","issuer":"rwdFmXzRpUC6DCcPedKSLaBZQyyCdnu72m","value":"0"},"LowNode":"0","PreviousTxnID":"FC53909735F24EDE36A3891E45B6D68BA0EEB60011842CABE68DFACE977D55E7","PreviousTxnLgrSeq":7425778,"index":"A960878EA69C01144C8D4A1768AB40A69D67A26E13226F5527FA2CFEFBED4BA4"},{"Balance":{"currency":"USD","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"1"},"Flags":65536,"HighLimit":{"currency":"USD","issuer":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","value":"0"},"HighNode":"0","LedgerEntryType":"RippleState","LowLimit":{"currency":"USD","issuer":"rD1jovjQeEpvaDwn9wKaYokkXXrqo4D23x","value":"1"},"LowNode":"0","PreviousTxnID":"72DC7DAD9B7BFDB234ED2EF6509C33513146EE0A71BD11F42C41E52DEBB7D0BB","PreviousTxnLgrSeq":1186213,"index":"8A2B79E75D1012CB89DBF27A0CE4750B398C353D679F5C1E22F8FAC6F87AE13C"},{"Balance":{"currency":"USD","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"1"},"Flags":65536,"HighLimit":{"currency":"USD","issuer":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","value":"0"},"HighNode":"0","LedgerEntryType":"RippleState","LowLimit":{"currency":"USD","issuer":"rDv5FHeAdRSsUpSa1mLyrZKhSoRXzotCvY","value":"1"},"LowNode":"0","PreviousTxnID":"74F7A29143B6AE9D51F36330E0DE8EC86AD76BB271AD8AA7B4EF4A5B555D6717","PreviousTxnLgrSeq":1186216,"index":"8FEFE9D3D63AFF7FBD9E0B82D958F94B6B20ED0A88A5DFB9CA23667B6FEADC2E"},{"Balance":{"currency":"USD","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"99999899"},"Flags":65536,"HighLimit":{"currency":"USD","issuer":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","value":"0"},"HighNode":"0","LedgerEntryType":"RippleState","LowLimit":{"currency":"USD","issuer":"rUjbzuRoQagHet3XnygZkfaTFTFkMoD3YG","value":"100000000"},"LowNode":"0","PreviousTxnID":"8F8606505F3E9F1EA81827AFCBC48D2AA405401C187649DCC5E193E210DD3A56","PreviousTxnLgrSeq":36777995,"index":"76F2877534D45442BEB33DDB6FFA1197F25CADDC116844E5C42DA109C424315F"},{"Balance":{"currency":"CNY","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"10000000"},"Flags":65536,"HighLimit":{"currency":"CNY","issuer":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","value":"0"},"HighNode":"0","LedgerEntryType":"RippleState","LowLimit":{"currency":"CNY","issuer":"rUjbzuRoQagHet3XnygZkfaTFTFkMoD3YG","value":"10000000"},"LowNode":"0","PreviousTxnID":"3B0F10F290EFCBCE0AE7939B3FB4A7D3AA2F9B8AC1FB6DF85291501EB2D141B5","PreviousTxnLgrSeq":5197997,"index":"DA97F35E5ADFCB65B42714472CA8BD2D48350558411DCB813E83B7360638AA48"},{"Balance":{"currency":"USD","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"900000000"},"Flags":65536,"HighLimit":{"currency":"USD","issuer":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","value":"0"},"HighNode":"0","LedgerEntryType":"RippleState","LowLimit":{"currency":"USD","issuer":"rfMwmKUJR53jbRratQVGmPJZLBZJiuKZ9a","value":"900000000"},"LowNode":"0","PreviousTxnID":"84C779372E8727BC70A24CC9390FB03E1C93C86873CF126D9F450AD337439519","PreviousTxnLgrSeq":5198246,"index":"248608E3FA6DE8276BD66FF284CC1DEB6EC7979AFD1EA684D561DC50A8CBE4A7"},{"Balance":{"currency":"USD","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"900"},"Flags":1114112,"HighLimit":{"currency":"USD","issuer":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","value":"0"},"HighNode":"0","LedgerEntryType":"RippleState","LowLimit":{"currency":"USD","issuer":"r9BFYMX3uNRpVkNXKihN7NmF1eBJvsXk4W","value":"900"},"LowNode":"0","PreviousTxnID":"7633E7DC2632DF9DD99B8453579BC48BD7ADA8C326D594F238DE0495665690F7","PreviousTxnLgrSeq":5198202,"index":"066F0919D4CA7279D859B3CA22FF27A077847355DC0F3539FC7ACC1871D9694F"},{"Balance":{"currency":"USD","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"0"},"Flags":1179648,"HighLimit":{"currency":"USD","issuer":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","value":"0"},"HighNode":"0","LedgerEntryType":"RippleState","LowLimit":{"currency":"USD","issuer":"rH3WTUovV1HKx4S5HZup4dUZEjeGnehL6X","value":"0"},"LowNode":"0","LowQualityIn":1000000000,"PreviousTxnID":"E08B5E76AC5425684FDB16FB03FC5962208FC0743A41EA70BA6A8961E3C9DC29","PreviousTxnLgrSeq":102048743,"index":"68715F553EFEA4B4A278A4AD001CD698700098BE096B1C052FC5579B0195870D"},{"Balance":{"currency":"USD","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"0"},"Flags":2162688,"HighLimit":{"currency":"USD","issuer":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","value":"0"},"HighNode":"0","LedgerEntryType":"RippleState","LowLimit":{"currency":"USD","issuer":"rUdMDNig18kb2E2ztP5kQsGhsDRqVtakMS","value":"50"},"LowNode":"0","PreviousTxnID":"1883F87A31518E16BAA912D90BEC8268F854434F93AE90A5130F7ADB7AF19695","PreviousTxnLgrSeq":38164749,"index":"F9B7952C77DB45BB0F1A9F2CB917AAB36C1F8230BF1BE9862F7E513390810F7B"},{"Balance":{"currency":"USD","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"0"},"Flags":2162688,"HighLimit":{"currency":"USD","issuer":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","value":"0"},"HighNode":"0","LedgerEntryType":"RippleState","LowLimit":{"currency":"USD","issuer":"rNdwi8ain5ibXNB9A7H3zzKtSxgVzAqqAe","value":"1000000000"},"LowNode":"ef","PreviousTxnID":"457F378D64533998666A2D8B864AF663AB17F92AFF0F223BDEBD83A1F22D0C40","PreviousTxnLgrSeq":26579691,"index":"E80B4D94D09CBF0A0CE5E9E5701C763007290B050F0A8908F44DD28DED4AE632"}],"ledger_current_index":106359163,"limit":10,"marker":"D8120FC732737A2CF2E9968FDF3797A43B457F2A81AA06D2653171A1EA635204,52E4CB3A86666ECE44A40D776510D48EE7BB37D7DA73F7DA1DA11DA4E19DDBDC","status":"success","validated":false,"warning":"load"}} + diff --git a/Tests/Xrpl.Tests/Fixtures/Responses/account_tx_raw.json b/Tests/Xrpl.Tests/Fixtures/Responses/account_tx_raw.json new file mode 100644 index 00000000..33730b97 --- /dev/null +++ b/Tests/Xrpl.Tests/Fixtures/Responses/account_tx_raw.json @@ -0,0 +1,2 @@ +{"result":{"account":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","ledger_index_max":106359161,"ledger_index_min":32570,"limit":10,"marker":{"ledger":106227617,"seq":104},"status":"success","transactions":[{"close_time_iso":"2026-08-17T04:43:20Z","hash":"EED332D31E575CEF52DFE837C33B3043ABECAD4669C32BC5BA2805A265D034EB","ledger_hash":"AA04669F4F10BEBAB43EA3C7688877E3E041CE6BC7F5E149CBB3DEABA818E791","ledger_index":106348355,"meta":{"AffectedNodes":[{"ModifiedNode":{"FinalFields":{"Account":"rE8jifWE6aQjS8JYt4RkFQUiLm5tBBnNjP","Balance":"36874563","Flags":0,"OwnerCount":128,"Sequence":106411983},"LedgerEntryType":"AccountRoot","LedgerIndex":"16D4FAFB0B2C1E6B9332EF56614CD575264E196C8CB857E4BB829BE0BB3C7A2E","PreviousFields":{"Balance":"36874573","OwnerCount":129,"Sequence":106411982},"PreviousTxnID":"F6994B397789A4976066E326847AF0122CE5CD5901E5314FF24D1C31E356EB63","PreviousTxnLgrSeq":106348355}},{"ModifiedNode":{"FinalFields":{"Flags":0,"IndexNext":"0","IndexPrevious":"7","Owner":"rE8jifWE6aQjS8JYt4RkFQUiLm5tBBnNjP","RootIndex":"79F32CB85371F10E4EED66B687A1BF11E06A0CABA07E7BBB87A03E0C82BC14EE"},"LedgerEntryType":"DirectoryNode","LedgerIndex":"23FFBE791BDEDDDDCE71A030A39B4198F314626FB0B5C3B58DD3B9C35362E41C","PreviousFields":{"IndexNext":"9"},"PreviousTxnID":"3C7ABE7B4CA086BDB3BE4BE35B25B0092636590C4E2CE828528A4BAF0A7E9AD7","PreviousTxnLgrSeq":106348355}},{"ModifiedNode":{"LedgerEntryType":"AccountRoot","LedgerIndex":"2B6AC232AA4C4BE41BF49D2459FA4A0347E1B543A4C92FCEE0821C0201E2E9A8","PreviousTxnID":"8C697FA496AF439BEA71C2519484448926E837936B59610C46EA1D57F81F223E","PreviousTxnLgrSeq":106348343}},{"ModifiedNode":{"FinalFields":{"Balance":{"currency":"5553445430000000000000000000000000000000","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"-2110070.924306605"},"Flags":131072,"HighLimit":{"currency":"5553445430000000000000000000000000000000","issuer":"rE8jifWE6aQjS8JYt4RkFQUiLm5tBBnNjP","value":"10000000000"},"HighNode":"0","LowLimit":{"currency":"5553445430000000000000000000000000000000","issuer":"rUNuVgEcbL5Po5RKGnq8DP1KukY6RkTSyz","value":"0"},"LowNode":"d"},"LedgerEntryType":"RippleState","LedgerIndex":"2F121B8ACFDE47A80C7FB53D8E8D6F469C15CAC9B6347A955621B3BCC85FA50E","PreviousFields":{"Balance":{"currency":"5553445430000000000000000000000000000000","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"-2105962.924306605"}},"PreviousTxnID":"F6994B397789A4976066E326847AF0122CE5CD5901E5314FF24D1C31E356EB63","PreviousTxnLgrSeq":106348355}},{"ModifiedNode":{"FinalFields":{"Flags":0,"IndexNext":"2","IndexPrevious":"8","Owner":"rE8jifWE6aQjS8JYt4RkFQUiLm5tBBnNjP","RootIndex":"79F32CB85371F10E4EED66B687A1BF11E06A0CABA07E7BBB87A03E0C82BC14EE"},"LedgerEntryType":"DirectoryNode","LedgerIndex":"79F32CB85371F10E4EED66B687A1BF11E06A0CABA07E7BBB87A03E0C82BC14EE","PreviousFields":{"IndexPrevious":"9"},"PreviousTxnID":"8C697FA496AF439BEA71C2519484448926E837936B59610C46EA1D57F81F223E","PreviousTxnLgrSeq":106348343}},{"ModifiedNode":{"FinalFields":{"Flags":0,"IndexNext":"0","IndexPrevious":"1c","Owner":"rUNuVgEcbL5Po5RKGnq8DP1KukY6RkTSyz","RootIndex":"4A89EF280F08A2122D8EF6538D84D78FE7FBA7A8276F215011FA7C803521CA41"},"LedgerEntryType":"DirectoryNode","LedgerIndex":"9303CBA9BAD56A3EB6CE64270D81DDE332167AA2FBD8018CF5E4AF1F987AD33E","PreviousTxnID":"F6994B397789A4976066E326847AF0122CE5CD5901E5314FF24D1C31E356EB63","PreviousTxnLgrSeq":106348355}},{"DeletedNode":{"FinalFields":{"Flags":0,"IndexPrevious":"8","Owner":"rE8jifWE6aQjS8JYt4RkFQUiLm5tBBnNjP","PreviousTxnID":"F6994B397789A4976066E326847AF0122CE5CD5901E5314FF24D1C31E356EB63","PreviousTxnLgrSeq":106348355,"RootIndex":"79F32CB85371F10E4EED66B687A1BF11E06A0CABA07E7BBB87A03E0C82BC14EE"},"LedgerEntryType":"DirectoryNode","LedgerIndex":"9425BEE6DF0C9B9DB7213FCD5F6DA695610C8DC09D009DA70B524B5BBF65D7B1"}},{"ModifiedNode":{"FinalFields":{"Flags":0,"IndexPrevious":"5","Owner":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","RootIndex":"D8120FC732737A2CF2E9968FDF3797A43B457F2A81AA06D2653171A1EA635204"},"LedgerEntryType":"DirectoryNode","LedgerIndex":"A7E734C5588267EB36F3E4E10F8CBA3019277823C3B3A69A7F2F7363AD94CA07","PreviousTxnID":"8C697FA496AF439BEA71C2519484448926E837936B59610C46EA1D57F81F223E","PreviousTxnLgrSeq":106348343}},{"DeletedNode":{"FinalFields":{"Account":"rE8jifWE6aQjS8JYt4RkFQUiLm5tBBnNjP","Amount":{"currency":"5553445430000000000000000000000000000000","issuer":"rUNuVgEcbL5Po5RKGnq8DP1KukY6RkTSyz","value":"4108"},"CancelAfter":840256989,"Condition":"A02580202672A273DAE6ACC5C658E1718AFFF5726D33CB390CF213C52C89D218666C4BA8810100","Destination":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","DestinationNode":"6","DestinationTag":3780162581,"FinishAfter":840256974,"Flags":0,"IssuerNode":"1d","OwnerNode":"9","PreviousTxnID":"8C697FA496AF439BEA71C2519484448926E837936B59610C46EA1D57F81F223E","PreviousTxnLgrSeq":106348343,"Sequence":106411957},"LedgerEntryType":"Escrow","LedgerIndex":"A84383C6C83E8417F8D53DF21EC6493A97BA760734550F76A1E611CFD5901F67"}}],"TransactionIndex":4,"TransactionResult":"tesSUCCESS"},"tx_json":{"Account":"rE8jifWE6aQjS8JYt4RkFQUiLm5tBBnNjP","Fee":"10","OfferSequence":106411957,"Owner":"rE8jifWE6aQjS8JYt4RkFQUiLm5tBBnNjP","Sequence":106411982,"SigningPubKey":"EDFF0DF05FCC413F0B12BA425AF586CF6F0D92CA888D1CFAA12C6FE841EBFBFCF1","TransactionType":"EscrowCancel","TxnSignature":"6DDD374F4939D10479471C3AD7256CDF4F60E81D49942D5983797CBE782CA32F27DF93CD849968EEB5F017E0C3A9DBA788793DB0A7B72C9EE498D51420736400","ctid":"C656BF4300040000","date":840257000,"ledger_index":106348355},"validated":true},{"close_time_iso":"2026-08-17T04:42:32Z","hash":"8C697FA496AF439BEA71C2519484448926E837936B59610C46EA1D57F81F223E","ledger_hash":"8A6F9FDBAE0D32B3FA86586F97F768C09319F4C5C246F87578D5EE9830C07493","ledger_index":106348343,"meta":{"AffectedNodes":[{"ModifiedNode":{"FinalFields":{"Account":"rE8jifWE6aQjS8JYt4RkFQUiLm5tBBnNjP","Balance":"36874813","Flags":0,"OwnerCount":145,"Sequence":106411958},"LedgerEntryType":"AccountRoot","LedgerIndex":"16D4FAFB0B2C1E6B9332EF56614CD575264E196C8CB857E4BB829BE0BB3C7A2E","PreviousFields":{"Balance":"36874823","OwnerCount":144,"Sequence":106411957},"PreviousTxnID":"8F18BBE7642DE120A430F3B20C0E61BBE281855F16939E1734B84FCC38A0B665","PreviousTxnLgrSeq":106348343}},{"ModifiedNode":{"FinalFields":{"Flags":0,"IndexNext":"9","IndexPrevious":"7","Owner":"rE8jifWE6aQjS8JYt4RkFQUiLm5tBBnNjP","RootIndex":"79F32CB85371F10E4EED66B687A1BF11E06A0CABA07E7BBB87A03E0C82BC14EE"},"LedgerEntryType":"DirectoryNode","LedgerIndex":"23FFBE791BDEDDDDCE71A030A39B4198F314626FB0B5C3B58DD3B9C35362E41C","PreviousFields":{"IndexNext":"0"},"PreviousTxnID":"8F18BBE7642DE120A430F3B20C0E61BBE281855F16939E1734B84FCC38A0B665","PreviousTxnLgrSeq":106348343}},{"ModifiedNode":{"LedgerEntryType":"AccountRoot","LedgerIndex":"2B6AC232AA4C4BE41BF49D2459FA4A0347E1B543A4C92FCEE0821C0201E2E9A8","PreviousTxnID":"B12968E13A68110199A98D0CC16AFD05DB433BAC842438141A6E0A3773BD056B","PreviousTxnLgrSeq":106325836}},{"ModifiedNode":{"FinalFields":{"Balance":{"currency":"5553445430000000000000000000000000000000","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"-2045948.924306605"},"Flags":131072,"HighLimit":{"currency":"5553445430000000000000000000000000000000","issuer":"rE8jifWE6aQjS8JYt4RkFQUiLm5tBBnNjP","value":"10000000000"},"HighNode":"0","LowLimit":{"currency":"5553445430000000000000000000000000000000","issuer":"rUNuVgEcbL5Po5RKGnq8DP1KukY6RkTSyz","value":"0"},"LowNode":"d"},"LedgerEntryType":"RippleState","LedgerIndex":"2F121B8ACFDE47A80C7FB53D8E8D6F469C15CAC9B6347A955621B3BCC85FA50E","PreviousFields":{"Balance":{"currency":"5553445430000000000000000000000000000000","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"-2050056.924306605"}},"PreviousTxnID":"8F18BBE7642DE120A430F3B20C0E61BBE281855F16939E1734B84FCC38A0B665","PreviousTxnLgrSeq":106348343}},{"ModifiedNode":{"FinalFields":{"Flags":0,"IndexNext":"2","IndexPrevious":"9","Owner":"rE8jifWE6aQjS8JYt4RkFQUiLm5tBBnNjP","RootIndex":"79F32CB85371F10E4EED66B687A1BF11E06A0CABA07E7BBB87A03E0C82BC14EE"},"LedgerEntryType":"DirectoryNode","LedgerIndex":"79F32CB85371F10E4EED66B687A1BF11E06A0CABA07E7BBB87A03E0C82BC14EE","PreviousFields":{"IndexPrevious":"8"},"PreviousTxnID":"2531E66DA1D6787F8F8102C18060568E7074D55F1C20B515325380378C94C525","PreviousTxnLgrSeq":106348342}},{"ModifiedNode":{"FinalFields":{"Flags":0,"IndexNext":"0","IndexPrevious":"1c","Owner":"rUNuVgEcbL5Po5RKGnq8DP1KukY6RkTSyz","RootIndex":"4A89EF280F08A2122D8EF6538D84D78FE7FBA7A8276F215011FA7C803521CA41"},"LedgerEntryType":"DirectoryNode","LedgerIndex":"9303CBA9BAD56A3EB6CE64270D81DDE332167AA2FBD8018CF5E4AF1F987AD33E","PreviousTxnID":"8F18BBE7642DE120A430F3B20C0E61BBE281855F16939E1734B84FCC38A0B665","PreviousTxnLgrSeq":106348343}},{"CreatedNode":{"LedgerEntryType":"DirectoryNode","LedgerIndex":"9425BEE6DF0C9B9DB7213FCD5F6DA695610C8DC09D009DA70B524B5BBF65D7B1","NewFields":{"IndexPrevious":"8","Owner":"rE8jifWE6aQjS8JYt4RkFQUiLm5tBBnNjP","RootIndex":"79F32CB85371F10E4EED66B687A1BF11E06A0CABA07E7BBB87A03E0C82BC14EE"}}},{"ModifiedNode":{"FinalFields":{"Flags":0,"IndexPrevious":"5","Owner":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","RootIndex":"D8120FC732737A2CF2E9968FDF3797A43B457F2A81AA06D2653171A1EA635204"},"LedgerEntryType":"DirectoryNode","LedgerIndex":"A7E734C5588267EB36F3E4E10F8CBA3019277823C3B3A69A7F2F7363AD94CA07","PreviousTxnID":"B12968E13A68110199A98D0CC16AFD05DB433BAC842438141A6E0A3773BD056B","PreviousTxnLgrSeq":106325836}},{"CreatedNode":{"LedgerEntryType":"Escrow","LedgerIndex":"A84383C6C83E8417F8D53DF21EC6493A97BA760734550F76A1E611CFD5901F67","NewFields":{"Account":"rE8jifWE6aQjS8JYt4RkFQUiLm5tBBnNjP","Amount":{"currency":"5553445430000000000000000000000000000000","issuer":"rUNuVgEcbL5Po5RKGnq8DP1KukY6RkTSyz","value":"4108"},"CancelAfter":840256989,"Condition":"A02580202672A273DAE6ACC5C658E1718AFFF5726D33CB390CF213C52C89D218666C4BA8810100","Destination":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","DestinationNode":"6","DestinationTag":3780162581,"FinishAfter":840256974,"IssuerNode":"1d","OwnerNode":"9","Sequence":106411957}}}],"TransactionIndex":53,"TransactionResult":"tesSUCCESS"},"tx_json":{"Account":"rE8jifWE6aQjS8JYt4RkFQUiLm5tBBnNjP","Amount":{"currency":"5553445430000000000000000000000000000000","issuer":"rUNuVgEcbL5Po5RKGnq8DP1KukY6RkTSyz","value":"4108"},"CancelAfter":840256989,"Condition":"A02580202672A273DAE6ACC5C658E1718AFFF5726D33CB390CF213C52C89D218666C4BA8810100","Destination":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","DestinationTag":3780162581,"Fee":"10","FinishAfter":840256974,"LastLedgerSequence":106348540,"Memos":[{"Memo":{"MemoData":"F09F92B52046696E616C697A6520457363726F7720617420E28692207573647872702E78797A","MemoType":"546574686572285852504C29"}}],"Sequence":106411957,"SigningPubKey":"EDFF0DF05FCC413F0B12BA425AF586CF6F0D92CA888D1CFAA12C6FE841EBFBFCF1","TransactionType":"EscrowCreate","TxnSignature":"5B566DFEB68B5A741195ACC083C5CF16A9EA5FF75E995BE778CFD0D7C64B298A1CB93FBBF625E476E08CCB32B4191FA7D78C035D440412FC2AF2E7DF50BCAD04","ctid":"C656BF3700350000","date":840256952,"ledger_index":106348343},"validated":true},{"close_time_iso":"2026-08-16T04:42:10Z","hash":"B12968E13A68110199A98D0CC16AFD05DB433BAC842438141A6E0A3773BD056B","ledger_hash":"751F3C3F17DCC73A0FEF1BB8C299A6E893C4A3B29A00CF1037E67C572AE79DB7","ledger_index":106325836,"meta":{"AffectedNodes":[{"ModifiedNode":{"LedgerEntryType":"AccountRoot","LedgerIndex":"2B6AC232AA4C4BE41BF49D2459FA4A0347E1B543A4C92FCEE0821C0201E2E9A8","PreviousTxnID":"28BA6E4FB986AC43BD697BB2B45BAAF56DEED73206BD35B02F30BE4311014F1B","PreviousTxnLgrSeq":106325822}},{"ModifiedNode":{"FinalFields":{"Balance":{"currency":"5553445430000000000000000000000000000000","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"2414650.924306605"},"Flags":65536,"HighLimit":{"currency":"5553445430000000000000000000000000000000","issuer":"rUNuVgEcbL5Po5RKGnq8DP1KukY6RkTSyz","value":"0"},"HighNode":"d","LowLimit":{"currency":"5553445430000000000000000000000000000000","issuer":"rhcozktE7FSVeDVg1EAuNRq9AL1ZZ9w7cj","value":"10000000000"},"LowNode":"0"},"LedgerEntryType":"RippleState","LedgerIndex":"62282E0DCBB0B6FE342DEC1F8D6DF328F65B8B31BA428B7189E2A3F550721BC8","PreviousFields":{"Balance":{"currency":"5553445430000000000000000000000000000000","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"2411408.924306605"}},"PreviousTxnID":"97C92A307C55CDFABAAE7B387B2B3520F7813D64ECC9235D2DA0CBC2D4599F0B","PreviousTxnLgrSeq":106325836}},{"ModifiedNode":{"FinalFields":{"Account":"rhcozktE7FSVeDVg1EAuNRq9AL1ZZ9w7cj","Balance":"25605321","Flags":0,"OwnerCount":48,"Sequence":106386889},"LedgerEntryType":"AccountRoot","LedgerIndex":"624FA2007A78E096EC4CA1696DDC1AFF1B5A64AD92D844C68D0811BD5A456A7C","PreviousFields":{"Balance":"25605331","OwnerCount":49,"Sequence":106386888},"PreviousTxnID":"97C92A307C55CDFABAAE7B387B2B3520F7813D64ECC9235D2DA0CBC2D4599F0B","PreviousTxnLgrSeq":106325836}},{"DeletedNode":{"FinalFields":{"Account":"rhcozktE7FSVeDVg1EAuNRq9AL1ZZ9w7cj","Amount":{"currency":"5553445430000000000000000000000000000000","issuer":"rUNuVgEcbL5Po5RKGnq8DP1KukY6RkTSyz","value":"3242"},"CancelAfter":840170512,"Condition":"A0258020033B10F4A9343B8D9A41817E0837EE69FFEEADC9001B9D96F739EC32DCE6BED9810100","Destination":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","DestinationNode":"6","DestinationTag":1040342953,"FinishAfter":840170497,"Flags":0,"IssuerNode":"11","OwnerNode":"4","PreviousTxnID":"28BA6E4FB986AC43BD697BB2B45BAAF56DEED73206BD35B02F30BE4311014F1B","PreviousTxnLgrSeq":106325822,"Sequence":106386845},"LedgerEntryType":"Escrow","LedgerIndex":"A5D285F6C2C2CC965F7D63B7E8F805ABB9C907A3313B47003ED870C155D97167"}},{"ModifiedNode":{"FinalFields":{"Flags":0,"IndexPrevious":"5","Owner":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","RootIndex":"D8120FC732737A2CF2E9968FDF3797A43B457F2A81AA06D2653171A1EA635204"},"LedgerEntryType":"DirectoryNode","LedgerIndex":"A7E734C5588267EB36F3E4E10F8CBA3019277823C3B3A69A7F2F7363AD94CA07","PreviousTxnID":"28BA6E4FB986AC43BD697BB2B45BAAF56DEED73206BD35B02F30BE4311014F1B","PreviousTxnLgrSeq":106325822}},{"ModifiedNode":{"FinalFields":{"Flags":0,"IndexPrevious":"3","Owner":"rhcozktE7FSVeDVg1EAuNRq9AL1ZZ9w7cj","RootIndex":"7E6B0E0A38BE70B066A93A8A57A87B3A9FA90021A9B22812B4A880FC113B66C8"},"LedgerEntryType":"DirectoryNode","LedgerIndex":"B2D0281B6AFCD064A715B135CA64E087CE9B232FEE42F6CC7F4CCFBD9206F719","PreviousTxnID":"97C92A307C55CDFABAAE7B387B2B3520F7813D64ECC9235D2DA0CBC2D4599F0B","PreviousTxnLgrSeq":106325836}},{"ModifiedNode":{"FinalFields":{"Flags":0,"IndexPrevious":"10","Owner":"rUNuVgEcbL5Po5RKGnq8DP1KukY6RkTSyz","RootIndex":"4A89EF280F08A2122D8EF6538D84D78FE7FBA7A8276F215011FA7C803521CA41"},"LedgerEntryType":"DirectoryNode","LedgerIndex":"F47A827717462C780A5A230DEB7F51C08C4D211027897D7C9B65C9E7194F49B6","PreviousTxnID":"97C92A307C55CDFABAAE7B387B2B3520F7813D64ECC9235D2DA0CBC2D4599F0B","PreviousTxnLgrSeq":106325836}}],"TransactionIndex":35,"TransactionResult":"tesSUCCESS"},"tx_json":{"Account":"rhcozktE7FSVeDVg1EAuNRq9AL1ZZ9w7cj","Fee":"10","OfferSequence":106386845,"Owner":"rhcozktE7FSVeDVg1EAuNRq9AL1ZZ9w7cj","Sequence":106386888,"SigningPubKey":"ED404B55C3A25026DAD049A3F07DD76592E7E0C9D209F28B86EE24A4921C430EEB","TransactionType":"EscrowCancel","TxnSignature":"318B7826F66297B30947F3C6DC25AEC4CFBB4AC135F0AC1EAA1126554D196D9E80882092397B2BFDE62B07A733CE7229A8BA1F4934C7B9D7D92D3F794EDA3F0C","ctid":"C656674C00230000","date":840170530,"ledger_index":106325836},"validated":true},{"close_time_iso":"2026-08-16T04:41:12Z","hash":"28BA6E4FB986AC43BD697BB2B45BAAF56DEED73206BD35B02F30BE4311014F1B","ledger_hash":"6122EE4FC6D3F3871D0787282057D83123EB00A8B6680E1B28F1F9D70D933A08","ledger_index":106325822,"meta":{"AffectedNodes":[{"ModifiedNode":{"LedgerEntryType":"AccountRoot","LedgerIndex":"2B6AC232AA4C4BE41BF49D2459FA4A0347E1B543A4C92FCEE0821C0201E2E9A8","PreviousTxnID":"27F1A9FBB23E4D21860DAEBC788D46C0912D046E1BCDCED13E1C389363102EC4","PreviousTxnLgrSeq":106282914}},{"ModifiedNode":{"FinalFields":{"Balance":{"currency":"5553445430000000000000000000000000000000","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"2278967.924306605"},"Flags":65536,"HighLimit":{"currency":"5553445430000000000000000000000000000000","issuer":"rUNuVgEcbL5Po5RKGnq8DP1KukY6RkTSyz","value":"0"},"HighNode":"d","LowLimit":{"currency":"5553445430000000000000000000000000000000","issuer":"rhcozktE7FSVeDVg1EAuNRq9AL1ZZ9w7cj","value":"10000000000"},"LowNode":"0"},"LedgerEntryType":"RippleState","LedgerIndex":"62282E0DCBB0B6FE342DEC1F8D6DF328F65B8B31BA428B7189E2A3F550721BC8","PreviousFields":{"Balance":{"currency":"5553445430000000000000000000000000000000","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"2282209.924306605"}},"PreviousTxnID":"8EB346739F3A2243D1E9296177D2D33056F183346D942A420568E571A5D3D665","PreviousTxnLgrSeq":106325822}},{"ModifiedNode":{"FinalFields":{"Account":"rhcozktE7FSVeDVg1EAuNRq9AL1ZZ9w7cj","Balance":"25605801","Flags":0,"OwnerCount":84,"Sequence":106386846},"LedgerEntryType":"AccountRoot","LedgerIndex":"624FA2007A78E096EC4CA1696DDC1AFF1B5A64AD92D844C68D0811BD5A456A7C","PreviousFields":{"Balance":"25605821","OwnerCount":83,"Sequence":106386845},"PreviousTxnID":"8EB346739F3A2243D1E9296177D2D33056F183346D942A420568E571A5D3D665","PreviousTxnLgrSeq":106325822}},{"CreatedNode":{"LedgerEntryType":"Escrow","LedgerIndex":"A5D285F6C2C2CC965F7D63B7E8F805ABB9C907A3313B47003ED870C155D97167","NewFields":{"Account":"rhcozktE7FSVeDVg1EAuNRq9AL1ZZ9w7cj","Amount":{"currency":"5553445430000000000000000000000000000000","issuer":"rUNuVgEcbL5Po5RKGnq8DP1KukY6RkTSyz","value":"3242"},"CancelAfter":840170512,"Condition":"A0258020033B10F4A9343B8D9A41817E0837EE69FFEEADC9001B9D96F739EC32DCE6BED9810100","Destination":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","DestinationNode":"6","DestinationTag":1040342953,"FinishAfter":840170497,"IssuerNode":"11","OwnerNode":"4","Sequence":106386845}}},{"ModifiedNode":{"FinalFields":{"Flags":0,"IndexPrevious":"5","Owner":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","RootIndex":"D8120FC732737A2CF2E9968FDF3797A43B457F2A81AA06D2653171A1EA635204"},"LedgerEntryType":"DirectoryNode","LedgerIndex":"A7E734C5588267EB36F3E4E10F8CBA3019277823C3B3A69A7F2F7363AD94CA07","PreviousTxnID":"27F1A9FBB23E4D21860DAEBC788D46C0912D046E1BCDCED13E1C389363102EC4","PreviousTxnLgrSeq":106282914}},{"ModifiedNode":{"FinalFields":{"Flags":0,"IndexPrevious":"3","Owner":"rhcozktE7FSVeDVg1EAuNRq9AL1ZZ9w7cj","RootIndex":"7E6B0E0A38BE70B066A93A8A57A87B3A9FA90021A9B22812B4A880FC113B66C8"},"LedgerEntryType":"DirectoryNode","LedgerIndex":"B2D0281B6AFCD064A715B135CA64E087CE9B232FEE42F6CC7F4CCFBD9206F719","PreviousTxnID":"8EB346739F3A2243D1E9296177D2D33056F183346D942A420568E571A5D3D665","PreviousTxnLgrSeq":106325822}},{"ModifiedNode":{"FinalFields":{"Flags":0,"IndexPrevious":"10","Owner":"rUNuVgEcbL5Po5RKGnq8DP1KukY6RkTSyz","RootIndex":"4A89EF280F08A2122D8EF6538D84D78FE7FBA7A8276F215011FA7C803521CA41"},"LedgerEntryType":"DirectoryNode","LedgerIndex":"F47A827717462C780A5A230DEB7F51C08C4D211027897D7C9B65C9E7194F49B6","PreviousTxnID":"8EB346739F3A2243D1E9296177D2D33056F183346D942A420568E571A5D3D665","PreviousTxnLgrSeq":106325822}}],"TransactionIndex":259,"TransactionResult":"tesSUCCESS"},"tx_json":{"Account":"rhcozktE7FSVeDVg1EAuNRq9AL1ZZ9w7cj","Amount":{"currency":"5553445430000000000000000000000000000000","issuer":"rUNuVgEcbL5Po5RKGnq8DP1KukY6RkTSyz","value":"3242"},"CancelAfter":840170512,"Condition":"A0258020033B10F4A9343B8D9A41817E0837EE69FFEEADC9001B9D96F739EC32DCE6BED9810100","Destination":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","DestinationTag":1040342953,"Fee":"20","FinishAfter":840170497,"LastLedgerSequence":106326018,"Memos":[{"Memo":{"MemoData":"F09F92B5E282AE2046696E616C697A6520457363726F77206174207573647872702E78797A"}}],"Sequence":106386845,"SigningPubKey":"ED404B55C3A25026DAD049A3F07DD76592E7E0C9D209F28B86EE24A4921C430EEB","TransactionType":"EscrowCreate","TxnSignature":"3F460E1C4E7C1A3490DCEB6346575E7FFC15F501FC7B574432A0FC604ECB6FFB51EC324A6EC713D943909559F962751912106A67270B8DAC5D0230A33F077703","ctid":"C656673E01030000","date":840170472,"ledger_index":106325822},"validated":true},{"close_time_iso":"2026-08-14T06:46:01Z","hash":"27F1A9FBB23E4D21860DAEBC788D46C0912D046E1BCDCED13E1C389363102EC4","ledger_hash":"EF1A8C8116F237FAE2354577157858931D4302FD49F3317851EA1531A46ADD4D","ledger_index":106282914,"meta":{"AffectedNodes":[{"ModifiedNode":{"FinalFields":{"Balance":{"currency":"5852504C00000000000000000000000000000000","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"-999869810"},"Flags":131072,"HighLimit":{"currency":"5852504C00000000000000000000000000000000","issuer":"rPK9VRtKuys1SRaWiqN7QnpKP2sSW4Y6vN","value":"10000000000"},"HighNode":"0","LowLimit":{"currency":"5852504C00000000000000000000000000000000","issuer":"rUNuVgEcbL5Po5RKGnq8DP1KukY6RkTSyz","value":"0"},"LowNode":"2"},"LedgerEntryType":"RippleState","LedgerIndex":"06D33535D0E2EA0DF172E605755626F17A9633FBF7CD85DE7C9D1E6A25A8FE7E","PreviousFields":{"Balance":{"currency":"5852504C00000000000000000000000000000000","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"-999866176"}},"PreviousTxnID":"E271EF3632CC517910D50E6E78BBFAD42398380ACADAB8C014000D69A9DEDB3F","PreviousTxnLgrSeq":106282914}},{"ModifiedNode":{"LedgerEntryType":"AccountRoot","LedgerIndex":"2B6AC232AA4C4BE41BF49D2459FA4A0347E1B543A4C92FCEE0821C0201E2E9A8","PreviousTxnID":"22EC57C0E483EF4B3A173B2859956A323DF801F87B3C2D51037BD6746EC9B7E0","PreviousTxnLgrSeq":106282893}},{"DeletedNode":{"FinalFields":{"Account":"rPK9VRtKuys1SRaWiqN7QnpKP2sSW4Y6vN","Amount":{"currency":"5852504C00000000000000000000000000000000","issuer":"rUNuVgEcbL5Po5RKGnq8DP1KukY6RkTSyz","value":"3634"},"CancelAfter":840005117,"Condition":"A0258020946E17C576E1BDAC3277F22AACFDD7DAEAC2215E49F6669AAD86943FC68D83D7810100","Destination":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","DestinationNode":"6","DestinationTag":3685042733,"FinishAfter":840005102,"Flags":0,"IssuerNode":"4","OwnerNode":"2","PreviousTxnID":"22EC57C0E483EF4B3A173B2859956A323DF801F87B3C2D51037BD6746EC9B7E0","PreviousTxnLgrSeq":106282893,"Sequence":106284888},"LedgerEntryType":"Escrow","LedgerIndex":"3DA49761021F4EDD99D61C3C64201F15050BD0351F36B1B50AB04AE7048EFFD3"}},{"ModifiedNode":{"FinalFields":{"Flags":0,"IndexNext":"5","IndexPrevious":"2","Owner":"rUNuVgEcbL5Po5RKGnq8DP1KukY6RkTSyz","RootIndex":"4A89EF280F08A2122D8EF6538D84D78FE7FBA7A8276F215011FA7C803521CA41"},"LedgerEntryType":"DirectoryNode","LedgerIndex":"5C570F24719818062106962879571494ACFB28F32E9B9DFAB8A76649E7480A1B","PreviousTxnID":"E271EF3632CC517910D50E6E78BBFAD42398380ACADAB8C014000D69A9DEDB3F","PreviousTxnLgrSeq":106282914}},{"ModifiedNode":{"FinalFields":{"Account":"rPK9VRtKuys1SRaWiqN7QnpKP2sSW4Y6vN","Balance":"34551330","Flags":0,"OwnerCount":35,"Sequence":106284988},"LedgerEntryType":"AccountRoot","LedgerIndex":"9EC057C59E63E1C7AD695FAD8194FF064C3F7DEA00BF80FDF7F9488B6ED5EADB","PreviousFields":{"Balance":"34551360","OwnerCount":36,"Sequence":106284987},"PreviousTxnID":"E271EF3632CC517910D50E6E78BBFAD42398380ACADAB8C014000D69A9DEDB3F","PreviousTxnLgrSeq":106282914}},{"ModifiedNode":{"FinalFields":{"Flags":0,"IndexPrevious":"5","Owner":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","RootIndex":"D8120FC732737A2CF2E9968FDF3797A43B457F2A81AA06D2653171A1EA635204"},"LedgerEntryType":"DirectoryNode","LedgerIndex":"A7E734C5588267EB36F3E4E10F8CBA3019277823C3B3A69A7F2F7363AD94CA07","PreviousTxnID":"22EC57C0E483EF4B3A173B2859956A323DF801F87B3C2D51037BD6746EC9B7E0","PreviousTxnLgrSeq":106282893}},{"ModifiedNode":{"FinalFields":{"Flags":0,"IndexNext":"3","IndexPrevious":"0","Owner":"rPK9VRtKuys1SRaWiqN7QnpKP2sSW4Y6vN","RootIndex":"43086954D5103204A66BC261AB689132558DC0E3AFBF67EC6E7FD168F81FF990"},"LedgerEntryType":"DirectoryNode","LedgerIndex":"F2EF439902C78CCB87D805AD7A42652586960139F549D97BFDDC409E73CE2B6A","PreviousTxnID":"E271EF3632CC517910D50E6E78BBFAD42398380ACADAB8C014000D69A9DEDB3F","PreviousTxnLgrSeq":106282914}}],"TransactionIndex":35,"TransactionResult":"tesSUCCESS"},"tx_json":{"Account":"rPK9VRtKuys1SRaWiqN7QnpKP2sSW4Y6vN","Fee":"30","OfferSequence":106284888,"Owner":"rPK9VRtKuys1SRaWiqN7QnpKP2sSW4Y6vN","Sequence":106284987,"SigningPubKey":"ED7CF1AE576DD8F834E8F32DE3D334E2F3428FD5C340E406C7EE3E1FDBACEDF8DA","TransactionType":"EscrowCancel","TxnSignature":"9AC8B56E449F02ADA620788C43BAE99D37A7DFB0734B3A4D5E5DF950BA546F0E8E800C68878108286C6852D22C05C78555CC34BF8C4150ACD0BC4B867B971007","ctid":"C655BFA200230000","date":840005161,"ledger_index":106282914},"validated":true},{"close_time_iso":"2026-08-14T06:44:41Z","hash":"22EC57C0E483EF4B3A173B2859956A323DF801F87B3C2D51037BD6746EC9B7E0","ledger_hash":"E731515C4A52138434D67A532214773A6F81E8F1CD3AABADB2D35CDC8D0B11C5","ledger_index":106282893,"meta":{"AffectedNodes":[{"ModifiedNode":{"FinalFields":{"Balance":{"currency":"5852504C00000000000000000000000000000000","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"-999699294"},"Flags":131072,"HighLimit":{"currency":"5852504C00000000000000000000000000000000","issuer":"rPK9VRtKuys1SRaWiqN7QnpKP2sSW4Y6vN","value":"10000000000"},"HighNode":"0","LowLimit":{"currency":"5852504C00000000000000000000000000000000","issuer":"rUNuVgEcbL5Po5RKGnq8DP1KukY6RkTSyz","value":"0"},"LowNode":"2"},"LedgerEntryType":"RippleState","LedgerIndex":"06D33535D0E2EA0DF172E605755626F17A9633FBF7CD85DE7C9D1E6A25A8FE7E","PreviousFields":{"Balance":{"currency":"5852504C00000000000000000000000000000000","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"-999702928"}},"PreviousTxnID":"668D0029F9DFF8570E8EC9C85FD4A0A52CE9F229938E25D79D3608AE93DA3FFB","PreviousTxnLgrSeq":106282893}},{"ModifiedNode":{"LedgerEntryType":"AccountRoot","LedgerIndex":"2B6AC232AA4C4BE41BF49D2459FA4A0347E1B543A4C92FCEE0821C0201E2E9A8","PreviousTxnID":"AB9D77240EE7414006F979CD8AF43BEAF9EC510F0E99DBFE7A2156BFB7DB56B6","PreviousTxnLgrSeq":106227646}},{"CreatedNode":{"LedgerEntryType":"Escrow","LedgerIndex":"3DA49761021F4EDD99D61C3C64201F15050BD0351F36B1B50AB04AE7048EFFD3","NewFields":{"Account":"rPK9VRtKuys1SRaWiqN7QnpKP2sSW4Y6vN","Amount":{"currency":"5852504C00000000000000000000000000000000","issuer":"rUNuVgEcbL5Po5RKGnq8DP1KukY6RkTSyz","value":"3634"},"CancelAfter":840005117,"Condition":"A0258020946E17C576E1BDAC3277F22AACFDD7DAEAC2215E49F6669AAD86943FC68D83D7810100","Destination":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","DestinationNode":"6","DestinationTag":3685042733,"FinishAfter":840005102,"IssuerNode":"4","OwnerNode":"2","Sequence":106284888}}},{"ModifiedNode":{"FinalFields":{"Flags":0,"IndexPrevious":"3","Owner":"rUNuVgEcbL5Po5RKGnq8DP1KukY6RkTSyz","RootIndex":"4A89EF280F08A2122D8EF6538D84D78FE7FBA7A8276F215011FA7C803521CA41"},"LedgerEntryType":"DirectoryNode","LedgerIndex":"5C570F24719818062106962879571494ACFB28F32E9B9DFAB8A76649E7480A1B","PreviousTxnID":"668D0029F9DFF8570E8EC9C85FD4A0A52CE9F229938E25D79D3608AE93DA3FFB","PreviousTxnLgrSeq":106282893}},{"ModifiedNode":{"FinalFields":{"Account":"rPK9VRtKuys1SRaWiqN7QnpKP2sSW4Y6vN","Balance":"34554300","Flags":0,"OwnerCount":81,"Sequence":106284889},"LedgerEntryType":"AccountRoot","LedgerIndex":"9EC057C59E63E1C7AD695FAD8194FF064C3F7DEA00BF80FDF7F9488B6ED5EADB","PreviousFields":{"Balance":"34554330","OwnerCount":80,"Sequence":106284888},"PreviousTxnID":"668D0029F9DFF8570E8EC9C85FD4A0A52CE9F229938E25D79D3608AE93DA3FFB","PreviousTxnLgrSeq":106282893}},{"ModifiedNode":{"FinalFields":{"Flags":0,"IndexPrevious":"5","Owner":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","RootIndex":"D8120FC732737A2CF2E9968FDF3797A43B457F2A81AA06D2653171A1EA635204"},"LedgerEntryType":"DirectoryNode","LedgerIndex":"A7E734C5588267EB36F3E4E10F8CBA3019277823C3B3A69A7F2F7363AD94CA07","PreviousTxnID":"BF86CE1AB85D385C7EF61A9739C351038D531D429D09E95FF6BB4B58F028BF8A","PreviousTxnLgrSeq":103798448}},{"ModifiedNode":{"FinalFields":{"Flags":0,"IndexPrevious":"1","Owner":"rPK9VRtKuys1SRaWiqN7QnpKP2sSW4Y6vN","RootIndex":"43086954D5103204A66BC261AB689132558DC0E3AFBF67EC6E7FD168F81FF990"},"LedgerEntryType":"DirectoryNode","LedgerIndex":"F2EF439902C78CCB87D805AD7A42652586960139F549D97BFDDC409E73CE2B6A","PreviousTxnID":"668D0029F9DFF8570E8EC9C85FD4A0A52CE9F229938E25D79D3608AE93DA3FFB","PreviousTxnLgrSeq":106282893}}],"TransactionIndex":63,"TransactionResult":"tesSUCCESS"},"tx_json":{"Account":"rPK9VRtKuys1SRaWiqN7QnpKP2sSW4Y6vN","Amount":{"currency":"5852504C00000000000000000000000000000000","issuer":"rUNuVgEcbL5Po5RKGnq8DP1KukY6RkTSyz","value":"3634"},"CancelAfter":840005117,"Condition":"A0258020946E17C576E1BDAC3277F22AACFDD7DAEAC2215E49F6669AAD86943FC68D83D7810100","Destination":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","DestinationTag":3685042733,"Fee":"30","FinishAfter":840005102,"LastLedgerSequence":106283083,"Memos":[{"Memo":{"MemoData":"F09F8E812046696E69736820457363726F77206174207872706C2D706F7274616C2E78797A"}}],"Sequence":106284888,"SigningPubKey":"ED7CF1AE576DD8F834E8F32DE3D334E2F3428FD5C340E406C7EE3E1FDBACEDF8DA","TransactionType":"EscrowCreate","TxnSignature":"3BC2985706C953D613741DA0493F17C0452186911704A345E7AE31CD2820AECB8B8032CC2704079F4A786815D4D7ECAA5CC655404EB0CEDCD9483E3335AE650E","ctid":"C655BF8D003F0000","date":840005081,"ledger_index":106282893},"validated":true},{"close_time_iso":"2026-08-11T19:05:21Z","hash":"AB9D77240EE7414006F979CD8AF43BEAF9EC510F0E99DBFE7A2156BFB7DB56B6","ledger_hash":"EF34AB62A70F097874459D14C89F57B1837E48338CCF83A6E92C91C6491ECA0E","ledger_index":106227646,"meta":{"AffectedNodes":[{"ModifiedNode":{"FinalFields":{"Account":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","Balance":"56774125592","Flags":1703936,"OwnerCount":1,"RegularKey":"rBmVUQNF6tJy4cLvoKdPXb4BNqKBk5JY1Y","Sequence":44196,"TransferRate":1220000000},"LedgerEntryType":"AccountRoot","LedgerIndex":"2B6AC232AA4C4BE41BF49D2459FA4A0347E1B543A4C92FCEE0821C0201E2E9A8","PreviousFields":{"Balance":"56774125590"},"PreviousTxnID":"A8893C55E0502FB4B8F65AE46C6AFA6370DAED07FF00E493EBD53E3516EAFA69","PreviousTxnLgrSeq":106227644}},{"ModifiedNode":{"FinalFields":{"Account":"rEPak6n2CEsQmowqsTMnkooskcLaGW9MzE","Balance":"10926684","Flags":0,"OwnerCount":0,"Sequence":98882929},"LedgerEntryType":"AccountRoot","LedgerIndex":"F9770CDF027F2EC1DC1F600D478B87F36EDF1ECAA26D2670259174E8835628D3","PreviousFields":{"Balance":"10926697","Sequence":98882928},"PreviousTxnID":"0C44FB76ED97F9AF052068964D964BD8E1D7122D343C3A85E1655F1BC39AA249","PreviousTxnLgrSeq":106227639}}],"TransactionIndex":0,"TransactionResult":"tesSUCCESS","delivered_amount":"2"},"tx_json":{"Account":"rEPak6n2CEsQmowqsTMnkooskcLaGW9MzE","DeliverMax":"2","Destination":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","DestinationTag":304200,"Fee":"11","Flags":0,"LastLedgerSequence":106227746,"Sequence":98882928,"SigningPubKey":"ED4C767651D1B94D9F1EAA11119442F8228D473E0778926C49D34D3ACE3AFE08B5","TransactionType":"Payment","TxnSignature":"1823144FA737B57DB3418069B7D0ED7FFA57E3FA9ED4434F27EA26056B3D2AFA5E0E608D5C3744B250C1376CFA17F8FA22A8CF371442DBA756ABA8BF7D59080F","ctid":"C654E7BE00000000","date":839790321,"ledger_index":106227646},"validated":true},{"close_time_iso":"2026-08-11T19:05:12Z","hash":"A8893C55E0502FB4B8F65AE46C6AFA6370DAED07FF00E493EBD53E3516EAFA69","ledger_hash":"A23E438287B31E597DE28A4DD0CFC919284033FDF503AA2BFC26EF293F6C307A","ledger_index":106227644,"meta":{"AffectedNodes":[{"ModifiedNode":{"FinalFields":{"Account":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","Balance":"56774125590","Flags":1703936,"OwnerCount":1,"RegularKey":"rBmVUQNF6tJy4cLvoKdPXb4BNqKBk5JY1Y","Sequence":44196,"TransferRate":1220000000},"LedgerEntryType":"AccountRoot","LedgerIndex":"2B6AC232AA4C4BE41BF49D2459FA4A0347E1B543A4C92FCEE0821C0201E2E9A8","PreviousFields":{"Balance":"56774125587"},"PreviousTxnID":"483D33C7E5C55E71E352D6546924AACE69AA166FAB48665EE6053597FAFD9CD9","PreviousTxnLgrSeq":106227642}},{"ModifiedNode":{"FinalFields":{"Account":"rHfMGaRpeGRRYxt1gQYLLN6WvNzZNa95es","Balance":"1263501","Flags":0,"OwnerCount":0,"Sequence":106151747},"LedgerEntryType":"AccountRoot","LedgerIndex":"AF4B112D1D16252A0E6870A166C1329C204FA4223D4BB74DD4AA1883B8D42149","PreviousFields":{"Balance":"1263514","Sequence":106151746},"PreviousTxnID":"9F365BB02A514033CB2F23D3A4CA119B204FB813D76E841864C3621688D4A707","PreviousTxnLgrSeq":106227643}}],"TransactionIndex":90,"TransactionResult":"tesSUCCESS","delivered_amount":"3"},"tx_json":{"Account":"rHfMGaRpeGRRYxt1gQYLLN6WvNzZNa95es","DeliverMax":"3","Destination":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","DestinationTag":12345,"Fee":"10","LastLedgerSequence":106227663,"Memos":[{"Memo":{"MemoData":"7B226964223A227435342D7872706C2D78343032222C226E616D65223A22466163696C697461746F7220627920743534206C616273227D","MemoFormat":"6170706C69636174696F6E2F6A736F6E"}}],"Sequence":106151746,"SigningPubKey":"ED03E5C01B29F23071216BC33B6A645F0174444F764925FF9F2AFD56E8276D30DF","TransactionType":"Payment","TxnSignature":"61E5B8EA2CCBAE56A4388668EF3E77AF2B88873748CBCB07AF2BD445E0655BA1F9579500D09DFA0D9DC5DC5D2284EE96742885361CAEB4A5B8AE8F071F6AF00C","ctid":"C654E7BC005A0000","date":839790312,"ledger_index":106227644},"validated":true},{"close_time_iso":"2026-08-11T19:05:10Z","hash":"483D33C7E5C55E71E352D6546924AACE69AA166FAB48665EE6053597FAFD9CD9","ledger_hash":"2A38011965352577817FC36E8E3C16E2FD424E9F9D0976439B918E4F13B373E1","ledger_index":106227642,"meta":{"AffectedNodes":[{"ModifiedNode":{"FinalFields":{"Account":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","Balance":"56774125587","Flags":1703936,"OwnerCount":1,"RegularKey":"rBmVUQNF6tJy4cLvoKdPXb4BNqKBk5JY1Y","Sequence":44196,"TransferRate":1220000000},"LedgerEntryType":"AccountRoot","LedgerIndex":"2B6AC232AA4C4BE41BF49D2459FA4A0347E1B543A4C92FCEE0821C0201E2E9A8","PreviousFields":{"Balance":"56774125577"},"PreviousTxnID":"968A8D59922FDC7AC7230D8A2EE863AA47D22574C962E5647E534024D5C3382F","PreviousTxnLgrSeq":106227619}},{"ModifiedNode":{"FinalFields":{"Account":"rEni1epjkJfVXMmMaDDWuz3hFe1mYnqfsk","Balance":"284395283","Flags":0,"OwnerCount":0,"Sequence":98149406},"LedgerEntryType":"AccountRoot","LedgerIndex":"7E3DB90434070065431DD17F84EA79AF65B7B7D86D5E0FAB49538B7BC7AF6589","PreviousFields":{"Balance":"284395305","Sequence":98149405},"PreviousTxnID":"8A2E7E4372E023509BEA40F1682CD0531EDFB2C2D3EEB58156AC245BA30D7EE2","PreviousTxnLgrSeq":106227612}}],"TransactionIndex":33,"TransactionResult":"tesSUCCESS","delivered_amount":"10"},"tx_json":{"Account":"rEni1epjkJfVXMmMaDDWuz3hFe1mYnqfsk","DeliverMax":"10","Destination":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","DestinationTag":12345,"Fee":"12","Flags":0,"LastLedgerSequence":106227660,"Sequence":98149405,"SigningPubKey":"ED3BC404165C0062C17025BE84A47BBCB9F9B1F091F63E6EFC732501213D86EE7C","TransactionType":"Payment","TxnSignature":"EE43D605C7B7681D541DC2712203BCBE932FE1B38EB05903684A337D9EDC22F2457F1748784A344E831F2EFD7E01DF1060A9AF9E77E2A377BB6F3BA44104EC09","ctid":"C654E7BA00210000","date":839790310,"ledger_index":106227642},"validated":true},{"close_time_iso":"2026-08-11T19:03:40Z","hash":"968A8D59922FDC7AC7230D8A2EE863AA47D22574C962E5647E534024D5C3382F","ledger_hash":"5FCD13E6822ACB043C77FBF2619C4D8B3E61235FFA0AB9CE2982A60CDAB4BDDF","ledger_index":106227619,"meta":{"AffectedNodes":[{"ModifiedNode":{"FinalFields":{"Account":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","Balance":"56774125577","Flags":1703936,"OwnerCount":1,"RegularKey":"rBmVUQNF6tJy4cLvoKdPXb4BNqKBk5JY1Y","Sequence":44196,"TransferRate":1220000000},"LedgerEntryType":"AccountRoot","LedgerIndex":"2B6AC232AA4C4BE41BF49D2459FA4A0347E1B543A4C92FCEE0821C0201E2E9A8","PreviousFields":{"Balance":"56774125574"},"PreviousTxnID":"41FC54FE96232D8406F6B887538AC8400B4EB0B0E6B8512EE157E8794E836F83","PreviousTxnLgrSeq":106227617}},{"ModifiedNode":{"FinalFields":{"Account":"rHfMGaRpeGRRYxt1gQYLLN6WvNzZNa95es","Balance":"1263605","Flags":0,"OwnerCount":0,"Sequence":106151739},"LedgerEntryType":"AccountRoot","LedgerIndex":"AF4B112D1D16252A0E6870A166C1329C204FA4223D4BB74DD4AA1883B8D42149","PreviousFields":{"Balance":"1263618","Sequence":106151738},"PreviousTxnID":"C840F61B8346DEF42AD325B8EAE8B628812C2B2ABBE4A9DBABE1EDD6D4308F51","PreviousTxnLgrSeq":106227617}}],"TransactionIndex":23,"TransactionResult":"tesSUCCESS","delivered_amount":"3"},"tx_json":{"Account":"rHfMGaRpeGRRYxt1gQYLLN6WvNzZNa95es","DeliverMax":"3","Destination":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","DestinationTag":12345,"Fee":"10","LastLedgerSequence":106227637,"Memos":[{"Memo":{"MemoData":"7B226964223A227435342D7872706C2D78343032222C226E616D65223A22466163696C697461746F7220627920743534206C616273227D","MemoFormat":"6170706C69636174696F6E2F6A736F6E"}}],"Sequence":106151738,"SigningPubKey":"ED03E5C01B29F23071216BC33B6A645F0174444F764925FF9F2AFD56E8276D30DF","TransactionType":"Payment","TxnSignature":"E1B3D270A97630AB178A75930D2E42C9ED078ADF4ADBD281D52E2A54A53D1152D978A686C6DD3E850571CD62D6B92C9B428514BB8FEE61A335EB329377F0B201","ctid":"C654E7A300170000","date":839790220,"ledger_index":106227619},"validated":true}],"validated":true}} + diff --git a/Tests/Xrpl.Tests/Fixtures/Responses/ledger_raw.json b/Tests/Xrpl.Tests/Fixtures/Responses/ledger_raw.json new file mode 100644 index 00000000..2de1aedb --- /dev/null +++ b/Tests/Xrpl.Tests/Fixtures/Responses/ledger_raw.json @@ -0,0 +1,2 @@ +{"result":{"ledger":{"account_hash":"D65BC295E298614947C224734E2C66BD7BADA6D7C82F9FBE7A9EA43DC327C1CA","close_flags":0,"close_time":840298580,"close_time_human":"2026-Aug-17 16:16:20.000000000 UTC","close_time_iso":"2026-08-17T16:16:20Z","close_time_resolution":10,"closed":true,"ledger_hash":"333BFF26AA0ADE0276162C6DFE82D3D94D0EC7054DFFE5BF2F7BAF47A1E02920","ledger_index":106359162,"parent_close_time":840298572,"parent_hash":"E266C538805F7751D16AB2C1810ED1182E8ED9023E411592FE5E97BE743A3115","total_coins":"99985627508883176","transaction_hash":"91C5E6AC94F08892E932B89C24B8ED83CBA1608508CCDC62B50AFF892AB86A99"},"ledger_hash":"333BFF26AA0ADE0276162C6DFE82D3D94D0EC7054DFFE5BF2F7BAF47A1E02920","ledger_index":106359162,"status":"success","validated":true}} + diff --git a/Tests/Xrpl.Tests/Fixtures/Responses/tx_binary_raw.json b/Tests/Xrpl.Tests/Fixtures/Responses/tx_binary_raw.json new file mode 100644 index 00000000..cce36d9d --- /dev/null +++ b/Tests/Xrpl.Tests/Fixtures/Responses/tx_binary_raw.json @@ -0,0 +1,2 @@ +{"result":{"close_time_iso":"2013-03-12T23:16:50Z","ctid":"C005523E00000000","hash":"E08D6E9754025BA2534A78707605E0601F03ACE063687A0CA1BDDACFCD1698C7","ledger_hash":"195F62F34EB2CCFA4C5888BA20387E82EB353DDB4508BAE6A835AF19FB8B0C09","ledger_index":348734,"meta_blob":"201C00000000F8E5110061250005521C55C26AA6B4F7C3B9F55E17CD0D11F12032A1C7AD2757229FFD277C9447A8815E6E56E0D7BDE68B468FF0B8D948FD865576517DA987569833A05374ADB9A72E870A06E62400000058624000000DD048AED9E1E7220000000024000000592D0000000B624000000DD048AECF811450FCCA71E98DFA43305149F9F0C7897DE5A9D18CE1E1E51100722500053E125553354D84BAE8FDFC3F4DA879D984D24B929E7FEB9100D2AD9EFCD2E126BCCDC856EA4BF03B4700123CDFFB6EB09DC1D6E28D5CEB7F680FB00FC24BC1C3BB2DB959E662800000000000000000000000000000000000000055534400000000000000000000000000000000000000000000000001E1E722000200003700000000000000003800000000000000006294838D7EA4C6800000000000000000000000000055534400000000000000000000000000000000000000000000000001668000000000000000000000000000000000000000555344000000000050FCCA71E98DFA43305149F9F0C7897DE5A9D18C67D5038D7EA4C6800000000000000000000000000055534400000000005E7B112523F68D2F5E879DB4EAC51C6698A69304E1E1F1031000","status":"success","tx_blob":"1200002200000000240000005861D4838D7EA4C6800000000000000000000000000055534400000000005E7B112523F68D2F5E879DB4EAC51C6698A6930468400000000000000A69D4839696F3392000000000000000000000000000555344000000000050FCCA71E98DFA43305149F9F0C7897DE5A9D18C732102EAE5DAB54DD8E1C49641D848D5B97D1B29149106174322EDF98A1B2CCE5D7F8E744630440220791B6A3E036ECEFFE99E8D4957564E8C84D1548C8C3E80A87ED1AA646ECCFB16022037C5CAC97E34E3021EBB426479F2ACF3ACA75DB91DCC48D1BCFB4CF547CFEAA0811450FCCA71E98DFA43305149F9F0C7897DE5A9D18C83145E7B112523F68D2F5E879DB4EAC51C6698A69304011231550FC62003E785DC231A1058A05E56E3F09CF4E60000000000000000000000005553440000000000550FC62003E785DC231A1058A05E56E3F09CF4E6FF318D3A0AEF277858BD4D9751ECECD16779C0CC86D000000000000000000000000055534400000000008D3A0AEF277858BD4D9751ECECD16779C0CC86D0317588B8DBDC8932DC410E8571045466C03F5A6B6900000000000000000000000055534400000000007588B8DBDC8932DC410E8571045466C03F5A6B6931550FC62003E785DC231A1058A05E56E3F09CF4E60000000000000000000000005553440000000000550FC62003E785DC231A1058A05E56E3F09CF4E600","validated":true}} + diff --git a/Tests/Xrpl.Tests/Fixtures/Responses/tx_raw.json b/Tests/Xrpl.Tests/Fixtures/Responses/tx_raw.json new file mode 100644 index 00000000..e85cae82 --- /dev/null +++ b/Tests/Xrpl.Tests/Fixtures/Responses/tx_raw.json @@ -0,0 +1,2 @@ +{"result":{"close_time_iso":"2013-03-12T23:16:50Z","ctid":"C005523E00000000","hash":"E08D6E9754025BA2534A78707605E0601F03ACE063687A0CA1BDDACFCD1698C7","ledger_hash":"195F62F34EB2CCFA4C5888BA20387E82EB353DDB4508BAE6A835AF19FB8B0C09","ledger_index":348734,"meta":{"AffectedNodes":[{"ModifiedNode":{"FinalFields":{"Account":"r3PDtZSa5LiYp1Ysn1vMuMzB59RzV3W9QH","Balance":"59328999119","Flags":0,"OwnerCount":11,"Sequence":89},"LedgerEntryType":"AccountRoot","LedgerIndex":"E0D7BDE68B468FF0B8D948FD865576517DA987569833A05374ADB9A72E870A06","PreviousFields":{"Balance":"59328999129","Sequence":88},"PreviousTxnID":"C26AA6B4F7C3B9F55E17CD0D11F12032A1C7AD2757229FFD277C9447A8815E6E","PreviousTxnLgrSeq":348700}},{"ModifiedNode":{"FinalFields":{"Balance":{"currency":"USD","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"-1"},"Flags":131072,"HighLimit":{"currency":"USD","issuer":"r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59","value":"100"},"HighNode":"0","LowLimit":{"currency":"USD","issuer":"r3PDtZSa5LiYp1Ysn1vMuMzB59RzV3W9QH","value":"0"},"LowNode":"0"},"LedgerEntryType":"RippleState","LedgerIndex":"EA4BF03B4700123CDFFB6EB09DC1D6E28D5CEB7F680FB00FC24BC1C3BB2DB959","PreviousFields":{"Balance":{"currency":"USD","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"0"}},"PreviousTxnID":"53354D84BAE8FDFC3F4DA879D984D24B929E7FEB9100D2AD9EFCD2E126BCCDC8","PreviousTxnLgrSeq":343570}}],"TransactionIndex":0,"TransactionResult":"tesSUCCESS","delivered_amount":"unavailable"},"status":"success","tx_json":{"Account":"r3PDtZSa5LiYp1Ysn1vMuMzB59RzV3W9QH","DeliverMax":{"currency":"USD","issuer":"r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59","value":"1"},"Destination":"r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59","Fee":"10","Flags":0,"Paths":[[{"account":"r3kmLJN5D28dHuH8vZNUZpMC43pEHpaocV","currency":"USD","issuer":"r3kmLJN5D28dHuH8vZNUZpMC43pEHpaocV","type":49}],[{"account":"rD1jovjQeEpvaDwn9wKaYokkXXrqo4D23x","currency":"USD","issuer":"rD1jovjQeEpvaDwn9wKaYokkXXrqo4D23x","type":49},{"account":"rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY","currency":"USD","issuer":"rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY","type":49},{"account":"r3kmLJN5D28dHuH8vZNUZpMC43pEHpaocV","currency":"USD","issuer":"r3kmLJN5D28dHuH8vZNUZpMC43pEHpaocV","type":49}]],"SendMax":{"currency":"USD","issuer":"r3PDtZSa5LiYp1Ysn1vMuMzB59RzV3W9QH","value":"1.01"},"Sequence":88,"SigningPubKey":"02EAE5DAB54DD8E1C49641D848D5B97D1B29149106174322EDF98A1B2CCE5D7F8E","TransactionType":"Payment","TxnSignature":"30440220791B6A3E036ECEFFE99E8D4957564E8C84D1548C8C3E80A87ED1AA646ECCFB16022037C5CAC97E34E3021EBB426479F2ACF3ACA75DB91DCC48D1BCFB4CF547CFEAA0","date":416445410,"ledger_index":348734},"validated":true}} + diff --git a/Tests/Xrpl.Tests/Fixtures/Responses/tx_v1_raw.json b/Tests/Xrpl.Tests/Fixtures/Responses/tx_v1_raw.json new file mode 100644 index 00000000..6cf63475 --- /dev/null +++ b/Tests/Xrpl.Tests/Fixtures/Responses/tx_v1_raw.json @@ -0,0 +1,2 @@ +{"result":{"Account":"r3PDtZSa5LiYp1Ysn1vMuMzB59RzV3W9QH","Amount":{"currency":"USD","issuer":"r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59","value":"1"},"DeliverMax":{"currency":"USD","issuer":"r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59","value":"1"},"Destination":"r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59","Fee":"10","Flags":0,"Paths":[[{"account":"r3kmLJN5D28dHuH8vZNUZpMC43pEHpaocV","currency":"USD","issuer":"r3kmLJN5D28dHuH8vZNUZpMC43pEHpaocV","type":49}],[{"account":"rD1jovjQeEpvaDwn9wKaYokkXXrqo4D23x","currency":"USD","issuer":"rD1jovjQeEpvaDwn9wKaYokkXXrqo4D23x","type":49},{"account":"rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY","currency":"USD","issuer":"rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY","type":49},{"account":"r3kmLJN5D28dHuH8vZNUZpMC43pEHpaocV","currency":"USD","issuer":"r3kmLJN5D28dHuH8vZNUZpMC43pEHpaocV","type":49}]],"SendMax":{"currency":"USD","issuer":"r3PDtZSa5LiYp1Ysn1vMuMzB59RzV3W9QH","value":"1.01"},"Sequence":88,"SigningPubKey":"02EAE5DAB54DD8E1C49641D848D5B97D1B29149106174322EDF98A1B2CCE5D7F8E","TransactionType":"Payment","TxnSignature":"30440220791B6A3E036ECEFFE99E8D4957564E8C84D1548C8C3E80A87ED1AA646ECCFB16022037C5CAC97E34E3021EBB426479F2ACF3ACA75DB91DCC48D1BCFB4CF547CFEAA0","ctid":"C005523E00000000","date":416445410,"hash":"E08D6E9754025BA2534A78707605E0601F03ACE063687A0CA1BDDACFCD1698C7","inLedger":348734,"ledger_index":348734,"meta":{"AffectedNodes":[{"ModifiedNode":{"FinalFields":{"Account":"r3PDtZSa5LiYp1Ysn1vMuMzB59RzV3W9QH","Balance":"59328999119","Flags":0,"OwnerCount":11,"Sequence":89},"LedgerEntryType":"AccountRoot","LedgerIndex":"E0D7BDE68B468FF0B8D948FD865576517DA987569833A05374ADB9A72E870A06","PreviousFields":{"Balance":"59328999129","Sequence":88},"PreviousTxnID":"C26AA6B4F7C3B9F55E17CD0D11F12032A1C7AD2757229FFD277C9447A8815E6E","PreviousTxnLgrSeq":348700}},{"ModifiedNode":{"FinalFields":{"Balance":{"currency":"USD","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"-1"},"Flags":131072,"HighLimit":{"currency":"USD","issuer":"r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59","value":"100"},"HighNode":"0","LowLimit":{"currency":"USD","issuer":"r3PDtZSa5LiYp1Ysn1vMuMzB59RzV3W9QH","value":"0"},"LowNode":"0"},"LedgerEntryType":"RippleState","LedgerIndex":"EA4BF03B4700123CDFFB6EB09DC1D6E28D5CEB7F680FB00FC24BC1C3BB2DB959","PreviousFields":{"Balance":{"currency":"USD","issuer":"rrrrrrrrrrrrrrrrrrrrBZbvji","value":"0"}},"PreviousTxnID":"53354D84BAE8FDFC3F4DA879D984D24B929E7FEB9100D2AD9EFCD2E126BCCDC8","PreviousTxnLgrSeq":343570}}],"TransactionIndex":0,"TransactionResult":"tesSUCCESS","delivered_amount":"unavailable"},"status":"success","validated":true}} + diff --git a/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs b/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs index e8135c9a..07a8a44b 100644 --- a/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs +++ b/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs @@ -43,7 +43,7 @@ public static async Task IsEnabledAsync(IXrplClient client, string amendme try { LedgerEntryRequest request = new LedgerEntryRequest { Index = AmendmentsLedgerIndex }; - JsonNode node = await client.GRequest(request); + JsonNode node = await client.GRequest(request).Typed(); JsonArray amendments = node?["node"]?["Amendments"]?.AsArray(); if (amendments != null) { @@ -74,7 +74,7 @@ private static async Task IsEnabledViaFeatureCommandAsync(IXrplClient clie try { FeatureRequest request = new FeatureRequest { Feature = amendmentId }; - JsonNode node = await client.GRequest(request); + JsonNode node = await client.GRequest(request).Typed(); JsonNode entry = node?[amendmentId]; return entry?["enabled"]?.GetValue() == true; } diff --git a/Tests/Xrpl.Tests/Integration/TestIAdminCredentials.cs b/Tests/Xrpl.Tests/Integration/TestIAdminCredentials.cs index 6828e546..3f71cd4f 100644 --- a/Tests/Xrpl.Tests/Integration/TestIAdminCredentials.cs +++ b/Tests/Xrpl.Tests/Integration/TestIAdminCredentials.cs @@ -78,7 +78,7 @@ public async Task TestAdminCommandSucceedsWithCredentials() try { Dictionary response = - await client.Request(new Dictionary(LedgerAccept)); + await client.Request(new Dictionary(LedgerAccept)).Typed(); Assert.IsTrue( response.ContainsKey("ledger_current_index"), diff --git a/Tests/Xrpl.Tests/Integration/Utils.cs b/Tests/Xrpl.Tests/Integration/Utils.cs index ae081f07..d59b4a02 100644 --- a/Tests/Xrpl.Tests/Integration/Utils.cs +++ b/Tests/Xrpl.Tests/Integration/Utils.cs @@ -441,7 +441,7 @@ public static async Task VerifySubmittedTransaction(IXrplClient client, object t hash = HashLedger.HashSignedTx(JsonNode.Parse( JsonSerializer.Serialize(tx, global::Xrpl.Client.Json.XrplJsonOptions.Default))); TxRequest request = new TxRequest(hash); - TransactionResponse data = await client.Tx(request); + TransactionResponse data = await client.TxV1(request).Typed(); } public static async Task TestTransaction(IXrplClient client, Dictionary transaction, XrplWallet wallet) diff --git a/Tests/Xrpl.Tests/Integration/requests/TestINFTClioCommands.cs b/Tests/Xrpl.Tests/Integration/requests/TestINFTClioCommands.cs new file mode 100644 index 00000000..01399ae0 --- /dev/null +++ b/Tests/Xrpl.Tests/Integration/requests/TestINFTClioCommands.cs @@ -0,0 +1,66 @@ +using System; +using System.Threading.Tasks; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Client; +using Xrpl.Client.Exceptions; +using Xrpl.Models.Methods; + +namespace XrplTests.Xrpl.ClientLib.Integration; + +/// +/// What a plain rippled node says when asked for a Clio-only command - issue #132. +/// +/// +/// +/// nft_info and nft_history are served by Clio, not by rippled, so the stand this +/// suite runs against cannot answer them. That is worth a test rather than a gap: a consumer who +/// needs to work against both has to be able to recognise the refusal and fall back to their own +/// crawl, and what they can recognise it by is exactly what is asserted here. +/// +/// +/// The shape of the answers themselves is covered by unit tests built from Clio's own handlers. +/// This is the other half - that asking a node which does not serve them fails in a way that says +/// so, instead of looking like a network problem or an empty result. +/// +/// +[TestClass] +public class TestINFTClioCommands +{ + private const string TokenId = "00190000E78F76A49DD9158FA85DA4AAD95C0767303CC4611D73BB4300C989A8"; + + private static IXrplClient client; + + [ClassInitialize] + public static async Task ClassInitializeAsync(TestContext testContext) + { + client = await IntegrationTestConfig.CreateClientAsync(TestNodeType.Standalone); + } + + [ClassCleanup] + public static void ClassCleanup() => client?.Dispose(); + + [TestMethod] + public async Task TestINFTInfoOnARippledNodeIsRefusedRecognisably() + { + RippledException error = await Assert.ThrowsExactlyAsync( + () => client.NFTInfo(new NFTInfoRequest(TokenId))); + + Assert.IsNotNull(error.Response, "The node's own answer must reach the caller, not just a message."); + Assert.AreEqual( + "unknownCmd", + error.Response.Error, + $"A caller falling back to their own crawl needs to recognise this by the error code. Node said: {error.Message}"); + } + + [TestMethod] + public async Task TestINFTHistoryOnARippledNodeIsRefusedRecognisably() + { + RippledException error = await Assert.ThrowsExactlyAsync( + () => client.NFTHistory(new NFTHistoryRequest(TokenId))); + + Assert.IsNotNull(error.Response); + Assert.AreEqual("unknownCmd", error.Response.Error, $"Node said: {error.Message}"); + } +} diff --git a/Tests/Xrpl.Tests/Integration/requests/TestIPathPayment.cs b/Tests/Xrpl.Tests/Integration/requests/TestIPathPayment.cs index 600a4a63..cebced82 100644 --- a/Tests/Xrpl.Tests/Integration/requests/TestIPathPayment.cs +++ b/Tests/Xrpl.Tests/Integration/requests/TestIPathPayment.cs @@ -98,7 +98,7 @@ await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, destinationAmount: destinationAmount ); - RipplePathFindResponse pathResponse = await client.RipplePathFind(pathRequest); + RipplePathFindResponse pathResponse = await client.RipplePathFind(pathRequest).Typed(); Assert.IsNotNull(pathResponse, "ripple_path_find response should not be null"); Assert.IsNotNull(pathResponse.Alternatives, "Alternatives should not be null"); @@ -262,7 +262,7 @@ await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, // Verify offer exists AccountOffersRequest offersReq = new AccountOffersRequest(walletMaker.ClassicAddress); - AccountOffers offersResp = await client.AccountOffers(offersReq); + AccountOffers offersResp = await client.AccountOffers(offersReq).Typed(); int offerCount = offersResp?.Offers?.Count ?? 0; Console.WriteLine($"[CrossCurrency] Maker has {offerCount} offer(s)"); Assert.IsTrue(offerCount > 0, "Maker offer must exist before pathfinding"); @@ -287,7 +287,7 @@ await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, } }; - RipplePathFindResponse pathResponse = await client.RipplePathFind(pathRequest); + RipplePathFindResponse pathResponse = await client.RipplePathFind(pathRequest).Typed(); Assert.IsNotNull(pathResponse, "ripple_path_find response should not be null"); Assert.IsNotNull(pathResponse.Alternatives, "Alternatives should not be null"); @@ -305,8 +305,8 @@ await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, { for (int i = 0; i < alt.PathsComputed.Count; i++) { - List steps = alt.PathsComputed[i]; - foreach (Path step in steps) + List steps = alt.PathsComputed[i]; + foreach (PathStep step in steps) { Console.WriteLine($"[CrossCurrency] step: type={step.Type} account={step.Account} currency={step.CurrencyCode} issuer={step.Issuer}"); } @@ -476,7 +476,7 @@ await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, SendMax = sendMax }; - RipplePathFindResponse pathResponse = await client.RipplePathFind(pathRequest); + RipplePathFindResponse pathResponse = await client.RipplePathFind(pathRequest).Typed(); Assert.IsNotNull(pathResponse, "ripple_path_find response should not be null"); Assert.IsNotNull(pathResponse.Alternatives, "Alternatives should not be null"); @@ -598,7 +598,7 @@ await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, SendMax = sendMax }; - RipplePathFindResponse pathResponse = await client.RipplePathFind(pathRequest); + RipplePathFindResponse pathResponse = await client.RipplePathFind(pathRequest).Typed(); Assert.IsNotNull(pathResponse, "ripple_path_find response should not be null"); Assert.IsNotNull(pathResponse.Alternatives, "Alternatives should not be null"); diff --git a/Tests/Xrpl.Tests/Integration/requests/accountChannels.cs b/Tests/Xrpl.Tests/Integration/requests/accountChannels.cs index da2492d2..c14e9df0 100644 --- a/Tests/Xrpl.Tests/Integration/requests/accountChannels.cs +++ b/Tests/Xrpl.Tests/Integration/requests/accountChannels.cs @@ -4,6 +4,7 @@ using Xrpl.Models.Ledger; using Xrpl.Models.Methods; +using Xrpl.Client; // https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/test/integration/requests/accountChannels.ts namespace XrplTests.Xrpl.ClientLib.Integration @@ -26,7 +27,7 @@ public async Task TestRequestMethod() { LedgerIndex index = new LedgerIndex(LedgerIndexType.Validated); AccountChannelsRequest request = new AccountChannelsRequest(runner.wallet.ClassicAddress) { LedgerIndex = index }; - AccountChannels response = await runner.client.AccountChannels(request); + AccountChannels response = await runner.client.AccountChannels(request).Typed(); Assert.IsNotNull(response); } } diff --git a/Tests/Xrpl.Tests/Integration/requests/accountCurrencies.cs b/Tests/Xrpl.Tests/Integration/requests/accountCurrencies.cs index f50dc55a..f83498eb 100644 --- a/Tests/Xrpl.Tests/Integration/requests/accountCurrencies.cs +++ b/Tests/Xrpl.Tests/Integration/requests/accountCurrencies.cs @@ -3,6 +3,7 @@ using Xrpl.Models.Common; using Xrpl.Models.Ledger; using Xrpl.Models.Methods; +using Xrpl.Client; //using XrplTests.Xrpl.ClientLib; // https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/test/integration/requests/accountCurrencies.ts @@ -28,7 +29,7 @@ public async Task TestRequestMethod() { LedgerIndex index = new LedgerIndex(LedgerIndexType.Validated); AccountCurrenciesRequest request = new AccountCurrenciesRequest(runner.wallet.ClassicAddress) { LedgerIndex = index, Strict = true }; - AccountCurrencies response = await runner.client.AccountCurrencies(request); + AccountCurrencies response = await runner.client.AccountCurrencies(request).Typed(); Assert.IsNotNull(response); } } diff --git a/Tests/Xrpl.Tests/Integration/requests/accountInfo.cs b/Tests/Xrpl.Tests/Integration/requests/accountInfo.cs index 4357b8b5..3deed9f5 100644 --- a/Tests/Xrpl.Tests/Integration/requests/accountInfo.cs +++ b/Tests/Xrpl.Tests/Integration/requests/accountInfo.cs @@ -8,6 +8,7 @@ using Xrpl.Models.Ledger; using Xrpl.Models.Methods; +using Xrpl.Client; namespace XrplTests.Xrpl.ClientLib.Integration { [TestClass] @@ -28,7 +29,7 @@ public async Task TestRequestMethod() { LedgerIndex index = new LedgerIndex(LedgerIndexType.Validated); AccountInfoRequest request = new AccountInfoRequest(runner.wallet.ClassicAddress) { LedgerIndex = index, Strict = true }; - AccountInfo accountInfo = await runner.client.AccountInfo(request); + AccountInfo accountInfo = await runner.client.AccountInfo(request).Typed(); Assert.IsNotNull(accountInfo); } } diff --git a/Tests/Xrpl.Tests/Integration/requests/accountLines.cs b/Tests/Xrpl.Tests/Integration/requests/accountLines.cs index 933e3d81..836e0cdf 100644 --- a/Tests/Xrpl.Tests/Integration/requests/accountLines.cs +++ b/Tests/Xrpl.Tests/Integration/requests/accountLines.cs @@ -8,6 +8,7 @@ using Xrpl.Models.Ledger; using Xrpl.Models.Methods; +using Xrpl.Client; namespace XrplTests.Xrpl.ClientLib.Integration { [TestClass] @@ -28,7 +29,7 @@ public async Task TestRequestMethod() { LedgerIndex index = new LedgerIndex(LedgerIndexType.Validated); AccountLinesRequest request = new AccountLinesRequest(runner.wallet.ClassicAddress) { LedgerIndex = index }; - AccountLines response = await runner.client.AccountLines(request); + AccountLines response = await runner.client.AccountLines(request).Typed(); Assert.IsNotNull(response); } } diff --git a/Tests/Xrpl.Tests/Integration/requests/accountObjects.cs b/Tests/Xrpl.Tests/Integration/requests/accountObjects.cs index e738d9b2..52ca4254 100644 --- a/Tests/Xrpl.Tests/Integration/requests/accountObjects.cs +++ b/Tests/Xrpl.Tests/Integration/requests/accountObjects.cs @@ -8,6 +8,7 @@ using Xrpl.Models.Ledger; using Xrpl.Models.Methods; +using Xrpl.Client; namespace XrplTests.Xrpl.ClientLib.Integration { [TestClass] @@ -28,7 +29,7 @@ public async Task TestRequestMethod() { LedgerIndex index = new LedgerIndex(LedgerIndexType.Validated); AccountObjectsRequest request = new AccountObjectsRequest(runner.wallet.ClassicAddress) { LedgerIndex = index }; - AccountObjects response = await runner.client.AccountObjects(request); + AccountObjects response = await runner.client.AccountObjects(request).Typed(); Assert.IsNotNull(response); } } diff --git a/Tests/Xrpl.Tests/Integration/requests/accountOffers.cs b/Tests/Xrpl.Tests/Integration/requests/accountOffers.cs index 57f9f0f8..bd8489bc 100644 --- a/Tests/Xrpl.Tests/Integration/requests/accountOffers.cs +++ b/Tests/Xrpl.Tests/Integration/requests/accountOffers.cs @@ -6,6 +6,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Xrpl.Models.Methods; +using Xrpl.Client; namespace XrplTests.Xrpl.ClientLib.Integration { [TestClass] @@ -25,7 +26,7 @@ public static async Task MyClassInitializeAsync(TestContext testContext) public async Task TestRequestMethod() { AccountOffersRequest request = new AccountOffersRequest(runner.wallet.ClassicAddress) { Strict = true }; - AccountOffers response = await runner.client.AccountOffers(request); + AccountOffers response = await runner.client.AccountOffers(request).Typed(); Assert.IsNotNull(response); } } diff --git a/Tests/Xrpl.Tests/Integration/requests/bookOffers.cs b/Tests/Xrpl.Tests/Integration/requests/bookOffers.cs index 77b1bfa3..0f5dd54f 100644 --- a/Tests/Xrpl.Tests/Integration/requests/bookOffers.cs +++ b/Tests/Xrpl.Tests/Integration/requests/bookOffers.cs @@ -7,6 +7,7 @@ using Xrpl.Models.Methods; using Xrpl.Models.Transactions; +using Xrpl.Client; namespace XrplTests.Xrpl.ClientLib.Integration { [TestClass] @@ -28,7 +29,7 @@ public async Task TestRequestMethod() TakerAmount takerGets = new TakerAmount { Currency = "XRP" }; TakerAmount takerPays = new TakerAmount { Currency = "USD", Issuer = runner.wallet.ClassicAddress }; BookOffersRequest request = new BookOffersRequest() { TakerGets = takerGets, TakerPays = takerPays }; - BookOffers bookOffers = await runner.client.BookOffers(request); + BookOffers bookOffers = await runner.client.BookOffers(request).Typed(); Assert.IsNotNull(bookOffers); } } diff --git a/Tests/Xrpl.Tests/Integration/requests/depositAuthorized.cs b/Tests/Xrpl.Tests/Integration/requests/depositAuthorized.cs index b0248828..1e67d6fc 100644 --- a/Tests/Xrpl.Tests/Integration/requests/depositAuthorized.cs +++ b/Tests/Xrpl.Tests/Integration/requests/depositAuthorized.cs @@ -14,6 +14,7 @@ using Xrpl.Utils.Hashes; using Xrpl.Wallet; +using Xrpl.Client; namespace XrplTests.Xrpl.ClientLib.Integration { [TestClass] @@ -43,7 +44,7 @@ public async Task TestRequestMethod() DestinationAccount = wallet2.ClassicAddress, }; - DepositAuthorized response = await runner.client.DepositAuthorized(request); + DepositAuthorized response = await runner.client.DepositAuthorized(request).Typed(); Assert.IsNotNull(response); Assert.AreEqual(runner.wallet.ClassicAddress, response.SourceAccount); @@ -92,7 +93,7 @@ public async Task TestRequestMethod_WithCredentials() Credentials = new List { credentialId }, }; - DepositAuthorized response = await runner.client.DepositAuthorized(request); + DepositAuthorized response = await runner.client.DepositAuthorized(request).Typed(); Assert.IsNotNull(response); Assert.AreEqual(walletSubject.ClassicAddress, response.SourceAccount); diff --git a/Tests/Xrpl.Tests/Integration/requests/fee.cs b/Tests/Xrpl.Tests/Integration/requests/fee.cs index 5c50a009..f9c65522 100644 --- a/Tests/Xrpl.Tests/Integration/requests/fee.cs +++ b/Tests/Xrpl.Tests/Integration/requests/fee.cs @@ -6,6 +6,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Xrpl.Models.Methods; +using Xrpl.Client; namespace XrplTests.Xrpl.ClientLib.Integration { [TestClass] @@ -24,7 +25,7 @@ public static async Task MyClassInitializeAsync(TestContext testContext) [TestMethod] public async Task TestRequestMethod() { - Fee response = await runner.client.Fee(); + Fee response = await runner.client.Fee().Typed(); Assert.IsNotNull(response); } } diff --git a/Tests/Xrpl.Tests/Integration/requests/gatewayBalances.cs b/Tests/Xrpl.Tests/Integration/requests/gatewayBalances.cs index ff4228f3..6425fd4f 100644 --- a/Tests/Xrpl.Tests/Integration/requests/gatewayBalances.cs +++ b/Tests/Xrpl.Tests/Integration/requests/gatewayBalances.cs @@ -118,7 +118,7 @@ await SubmitAsync(new TrustSet Strict = true, HotWallet = hotWallet.ClassicAddress, }; - GatewayBalancesResponse response = await client.GatewayBalances(request); + GatewayBalancesResponse response = await client.GatewayBalances(request).Typed(); Assert.IsNotNull(response); Assert.AreEqual(issuer.ClassicAddress, response.Account); diff --git a/Tests/Xrpl.Tests/Integration/requests/ledger.cs b/Tests/Xrpl.Tests/Integration/requests/ledger.cs index c66316cd..c1397947 100644 --- a/Tests/Xrpl.Tests/Integration/requests/ledger.cs +++ b/Tests/Xrpl.Tests/Integration/requests/ledger.cs @@ -8,6 +8,7 @@ using Xrpl.Models.Ledger; using Xrpl.Models.Methods; +using Xrpl.Client; namespace XrplTests.Xrpl.ClientLib.Integration { [TestClass] @@ -31,7 +32,7 @@ public async Task TestRequestMethod() { LedgerIndex = index, }; - LOLedger response = await runner.client.Ledger(request); + LOLedger response = await runner.client.Ledger(request).Typed(); Assert.IsNotNull(response); } } diff --git a/Tests/Xrpl.Tests/Integration/requests/ledgerClosed.cs b/Tests/Xrpl.Tests/Integration/requests/ledgerClosed.cs index 9b52e4d0..16e8c0b5 100644 --- a/Tests/Xrpl.Tests/Integration/requests/ledgerClosed.cs +++ b/Tests/Xrpl.Tests/Integration/requests/ledgerClosed.cs @@ -8,6 +8,7 @@ using Xrpl.Models.Ledger; using Xrpl.Models.Methods; +using Xrpl.Client; namespace XrplTests.Xrpl.ClientLib.Integration { [TestClass] @@ -28,7 +29,7 @@ public async Task TestRequestMethod() { LedgerIndex index = new LedgerIndex(LedgerIndexType.Validated); LedgerClosedRequest request = new LedgerClosedRequest(); - LOBaseLedger response = await runner.client.LedgerClosed(request); + LOBaseLedger response = await runner.client.LedgerClosed(request).Typed(); Assert.IsNotNull(response); } } diff --git a/Tests/Xrpl.Tests/Integration/requests/ledgerCurrent.cs b/Tests/Xrpl.Tests/Integration/requests/ledgerCurrent.cs index ec3bd0f9..5795df10 100644 --- a/Tests/Xrpl.Tests/Integration/requests/ledgerCurrent.cs +++ b/Tests/Xrpl.Tests/Integration/requests/ledgerCurrent.cs @@ -8,6 +8,7 @@ using Xrpl.Models.Ledger; using Xrpl.Models.Methods; +using Xrpl.Client; namespace XrplTests.Xrpl.ClientLib.Integration { [TestClass] @@ -28,7 +29,7 @@ public async Task TestRequestMethod() { LedgerIndex index = new LedgerIndex(LedgerIndexType.Validated); LedgerCurrentRequest request = new LedgerCurrentRequest(); - LOLedgerCurrentIndex response = await runner.client.LedgerCurrent(request); + LOLedgerCurrentIndex response = await runner.client.LedgerCurrent(request).Typed(); Assert.IsNotNull(response); } } diff --git a/Tests/Xrpl.Tests/Integration/requests/ledgerData.cs b/Tests/Xrpl.Tests/Integration/requests/ledgerData.cs index 2c70444c..c543b22a 100644 --- a/Tests/Xrpl.Tests/Integration/requests/ledgerData.cs +++ b/Tests/Xrpl.Tests/Integration/requests/ledgerData.cs @@ -8,6 +8,7 @@ using Xrpl.Models.Ledger; using Xrpl.Models.Methods; +using Xrpl.Client; namespace XrplTests.Xrpl.ClientLib.Integration { [TestClass] @@ -28,7 +29,7 @@ public async Task TestRequestMethod() { LedgerIndex index = new LedgerIndex(LedgerIndexType.Validated); LedgerDataRequest request = new LedgerDataRequest(); - LOLedgerData response = await runner.client.LedgerData(request); + LOLedgerData response = await runner.client.LedgerData(request).Typed(); Assert.IsNotNull(response); } } diff --git a/Tests/Xrpl.Tests/Integration/requests/noRippleCheck.cs b/Tests/Xrpl.Tests/Integration/requests/noRippleCheck.cs index af8c30cc..8aa4b624 100644 --- a/Tests/Xrpl.Tests/Integration/requests/noRippleCheck.cs +++ b/Tests/Xrpl.Tests/Integration/requests/noRippleCheck.cs @@ -8,6 +8,7 @@ using Xrpl.Models.Ledger; using Xrpl.Models.Methods; +using Xrpl.Client; namespace XrplTests.Xrpl.ClientLib.Integration { [TestClass] @@ -28,7 +29,7 @@ public async Task TestRequestMethod() { LedgerIndex index = new LedgerIndex(LedgerIndexType.Validated); NoRippleCheckRequest request = new NoRippleCheckRequest(runner.wallet.ClassicAddress); - NoRippleCheck response = await runner.client.NoRippleCheck(request); + NoRippleCheck response = await runner.client.NoRippleCheck(request).Typed(); Assert.IsNotNull(response); } } diff --git a/Tests/Xrpl.Tests/Integration/requests/pathFind.cs b/Tests/Xrpl.Tests/Integration/requests/pathFind.cs index cfa04fb3..fce8cd72 100644 --- a/Tests/Xrpl.Tests/Integration/requests/pathFind.cs +++ b/Tests/Xrpl.Tests/Integration/requests/pathFind.cs @@ -57,7 +57,7 @@ public async Task TestPathFindCreate() destinationAmount: destinationAmount ); - PathFindResponse response = await pfClient.PathFind(request); + PathFindResponse response = await pfClient.PathFind(request).Typed(); Assert.IsNotNull(response); Assert.IsNotNull(response.Alternatives); Assert.AreEqual(wallet.ClassicAddress, response.DestinationAccount); @@ -94,7 +94,7 @@ public async Task TestPathFindClose() await pfClient.PathFind(createRequest); PathFindCloseRequest closeRequest = new PathFindCloseRequest(); - PathFindResponse closeResponse = await pfClient.PathFindClose(closeRequest); + PathFindResponse closeResponse = await pfClient.PathFindClose(closeRequest).Typed(); Assert.IsNotNull(closeResponse); Assert.IsTrue(closeResponse.Closed.HasValue && closeResponse.Closed.Value); } @@ -129,7 +129,7 @@ public async Task TestPathFindStatus() await pfClient.PathFind(createRequest); PathFindStatusRequest statusRequest = new PathFindStatusRequest(); - PathFindResponse statusResponse = await pfClient.PathFindStatus(statusRequest); + PathFindResponse statusResponse = await pfClient.PathFindStatus(statusRequest).Typed(); Assert.IsNotNull(statusResponse); Assert.IsNotNull(statusResponse.Alternatives); } @@ -178,7 +178,7 @@ public async Task TestPathFindStreamReceivesMultipleUpdates() destinationAmount: destinationAmount ); - PathFindResponse response = await streamClient.PathFind(request); + PathFindResponse response = await streamClient.PathFind(request).Typed(); Assert.IsNotNull(response, "Initial path_find create response should not be null"); Console.WriteLine($"[PathFind RPC] destination={response.DestinationAccount}, alternatives={response.Alternatives?.Count}"); @@ -317,7 +317,7 @@ await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, SendMax = sendMax }; - PathFindResponse response = await pfClient.PathFind(request); + PathFindResponse response = await pfClient.PathFind(request).Typed(); Assert.IsNotNull(response, "path_find create response should not be null"); Assert.IsNotNull(response.Alternatives, "Alternatives should not be null"); @@ -440,7 +440,7 @@ await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, SendMax = sendMax }; - PathFindResponse response = await pfClient.PathFind(request); + PathFindResponse response = await pfClient.PathFind(request).Typed(); Assert.IsNotNull(response, "path_find create response should not be null"); Assert.IsNotNull(response.Alternatives, "Alternatives should not be null"); diff --git a/Tests/Xrpl.Tests/Integration/requests/ripplePathFind.cs b/Tests/Xrpl.Tests/Integration/requests/ripplePathFind.cs index 70c31476..e8e04267 100644 --- a/Tests/Xrpl.Tests/Integration/requests/ripplePathFind.cs +++ b/Tests/Xrpl.Tests/Integration/requests/ripplePathFind.cs @@ -50,7 +50,7 @@ public async Task TestRequestMethod() destinationAmount: destinationAmount ); - RipplePathFindResponse response = await client.RipplePathFind(request); + RipplePathFindResponse response = await client.RipplePathFind(request).Typed(); Assert.IsNotNull(response); Assert.IsNotNull(response.Alternatives); Assert.IsNotNull(response.DestinationCurrencies); @@ -82,7 +82,7 @@ public async Task TestRequestWithSourceCurrencies() } }; - RipplePathFindResponse response = await client.RipplePathFind(request); + RipplePathFindResponse response = await client.RipplePathFind(request).Typed(); Assert.IsNotNull(response); Assert.IsNotNull(response.Alternatives); } diff --git a/Tests/Xrpl.Tests/Integration/requests/serverInfo.cs b/Tests/Xrpl.Tests/Integration/requests/serverInfo.cs index 76d0810e..9b38c0ff 100644 --- a/Tests/Xrpl.Tests/Integration/requests/serverInfo.cs +++ b/Tests/Xrpl.Tests/Integration/requests/serverInfo.cs @@ -6,6 +6,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Xrpl.Models.Methods; +using Xrpl.Client; namespace XrplTests.Xrpl.ClientLib.Integration { [TestClass] @@ -25,7 +26,7 @@ public static async Task MyClassInitializeAsync(TestContext testContext) public async Task TestRequestMethod() { ServerInfoRequest request = new ServerInfoRequest(); - ServerInfo response = await runner.client.ServerInfo(request); + ServerInfo response = await runner.client.ServerInfo(request).Typed(); Assert.IsNotNull(response); } } diff --git a/Tests/Xrpl.Tests/Integration/requests/tx.cs b/Tests/Xrpl.Tests/Integration/requests/tx.cs index 90094d78..be094d8f 100644 --- a/Tests/Xrpl.Tests/Integration/requests/tx.cs +++ b/Tests/Xrpl.Tests/Integration/requests/tx.cs @@ -7,6 +7,7 @@ using Xrpl.Models.Transactions; using Xrpl.Utils.Hashes; +using Xrpl.Client; // https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/test/integration/requests/tx.ts namespace XrplTests.Xrpl.ClientLib.Integration @@ -39,7 +40,7 @@ public async Task TestRequestMethod() Submit response = await runner.client.Submit(txRequest, runner.wallet); string hash = HashLedger.HashSignedTx(response.TxBlob); TxRequest request1 = new TxRequest(hash); - TransactionResponse accountTx = await runner.client.Tx(request1); + TransactionResponse accountTx = await runner.client.TxV1(request1).Typed(); Assert.IsNotNull(accountTx); } } diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIAMMBase.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIAMMBase.cs index 92bdc91c..945f23f5 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIAMMBase.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIAMMBase.cs @@ -136,7 +136,7 @@ protected async Task GetAmmInfo() { Asset = TokenAsset, Asset2 = XrpAsset - }); + }).Typed(); } protected static void AssertSuccess(TransactionSummary res, string context) diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIAMMWithdrawAdvanced.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIAMMWithdrawAdvanced.cs index 78e26b5f..79fd1dde 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIAMMWithdrawAdvanced.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIAMMWithdrawAdvanced.cs @@ -160,7 +160,7 @@ public async Task TestAMMWithdraw_Simulate_ThenSubmit() SimulateResponse simResult = await client.Simulate(new SimulateRequest { Transaction = autofilledForSim - }); + }).Typed(); Console.WriteLine($"Simulate result: {simResult.EngineResult}"); Assert.IsTrue( diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIAmmMathAgainstTheNode.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIAmmMathAgainstTheNode.cs new file mode 100644 index 00000000..549c6037 --- /dev/null +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIAmmMathAgainstTheNode.cs @@ -0,0 +1,451 @@ +using System; +using System.Threading.Tasks; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Client; +using Xrpl.Models.Common; +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; +using Xrpl.Models.Transactions; +using Xrpl.Wallet; +using Xrpl.Sugar; + +namespace XrplTests.Xrpl.ClientLib.Integration; + +/// +/// credits what the node credits - issue #133. +/// +/// +/// +/// The unit tests prove the formulas were transcribed correctly from rippled's +/// AMMHelpers.cpp. They cannot prove they are the right formulas: a faithful copy of the +/// wrong equation passes every one of them. Only a node settles that, so this deposits into a real +/// pool and compares the estimate with what was actually credited. +/// +/// +/// The comparison is tight on purpose. The approximation this class replaces is out by 0.08%, so a +/// loose tolerance would pass with the wrong formula and prove nothing; the bound here is five +/// orders of magnitude tighter than that error. +/// +/// +/// It is not tighter still, and the gap is deliberate. What these tests compare is the difference +/// of two reported LP token balances, so the last of STAmount's 15 significant digits is +/// lost to cancellation before the comparison happens; the measured agreement is around 1e-15, and +/// asserting anywhere near that would buy brittleness rather than coverage. At 1e-9 there are six +/// orders of margin over what is measured and five over the error being guarded against. +/// +/// +/// These tests also demonstrate the trap the report names first, because the first version of them +/// fell into it: AMMCreate hands the auction slot to the pool's creator, so the account +/// depositing here trades at DiscountedFee - a tenth of the pool's fee - and the node +/// credits it accordingly. Estimating at the pool's fee was out by 0.23%, three times the error of +/// the approximation this class exists to replace, with correct formulas throughout. Each test +/// asserts both halves: the right fee matches, and the pool's fee visibly does not. +/// +/// +[TestClass] +public class TestIAmmMathAgainstTheNode : TestIAMMBase +{ + private static IXrplClient client; + protected override IXrplClient GetClient() => client; + + [ClassInitialize] + public static async Task ClassInitializeAsync(TestContext testContext) + { + client = await CreateStandaloneClient(); + } + + [ClassCleanup] + public static void ClassCleanup() => client?.Dispose(); + + [TestMethod] + public async Task TestIASingleAssetDepositIsCreditedAsEstimated() + { + await CreatePool(); + + // Read immediately before the calculation, not once at the start: an estimate run against a + // stale pool drifts in a way that looks exactly like an error in the arithmetic. That is one + // of the two things the report names as making a correct formula appear wrong. + AMMInfoResponse before = await GetAmmInfo(); + decimal poolBalance = before.Amm.Amount.ValueAsNumber; + decimal lpBefore = before.Amm.LPTokenBalance.ValueAsNumber; + uint poolFee = before.Amm.TradingFee; + uint effectiveFee = FeeFor(before, walletHolder.ClassicAddress); + + const decimal Deposit = 500m; + + decimal estimated = AmmMath.LPTokensForSingleAssetDeposit( + poolBalance, + Deposit, + lpBefore, + effectiveFee); + decimal atPoolFee = AmmMath.LPTokensForSingleAssetDeposit( + poolBalance, + Deposit, + lpBefore, + poolFee); + + AMMDeposit deposit = new AMMDeposit + { + Account = walletHolder.ClassicAddress, + Asset = TokenAsset, + Asset2 = XrpAsset, + Amount = new Currency + { + CurrencyCode = CurrencyCode, + Issuer = walletIssuer.ClassicAddress, + Value = Deposit.ToString(System.Globalization.CultureInfo.InvariantCulture), + }, + Flags = AMMDepositFlags.tfSingleAsset, + }; + + ITransactionRequest autofilled = await client.Autofill(deposit); + TransactionSummary result = await client.SubmitAndWait(autofilled, walletHolder, true); + AssertSuccess(result, "AMMDeposit single asset"); + + AMMInfoResponse after = await GetAmmInfo(); + decimal credited = after.Amm.LPTokenBalance.ValueAsNumber - lpBefore; + + decimal relativeError = Math.Abs(credited - estimated) / credited; + + Console.WriteLine( + $"pool {poolBalance}, pool fee {poolFee}, effective fee {effectiveFee}, deposit {Deposit}: " + + $"estimated {estimated}, credited {credited}, relative error {relativeError}"); + + Assert.IsTrue( + relativeError < 0.000000001m, + $"Estimated {estimated} against {credited} actually credited - a relative error of " + + $"{relativeError}, against a bound of 1e-9. The approximation this class exists to " + + $"replace is out by 8e-4, so anything near that means the wrong equation was used."); + + Assert.AreNotEqual( + poolFee, + effectiveFee, + "This account holds the auction slot, which is what makes the second assertion mean something."); + Assert.IsTrue( + Math.Abs(atPoolFee - credited) / credited > 0.001m, + $"Estimating at the pool's fee instead of the slot holder's must be visibly wrong, and is: " + + $"{atPoolFee} against {credited}."); + } + + /// + /// And the withdrawal side, which the unit tests can only pin through an identity. + /// + [TestMethod] + public async Task TestIASingleAssetWithdrawCostsAsEstimated() + { + await CreatePool(); + + AMMInfoResponse before = await GetAmmInfo(); + decimal poolBalance = before.Amm.Amount.ValueAsNumber; + decimal lpBefore = before.Amm.LPTokenBalance.ValueAsNumber; + uint poolFee = before.Amm.TradingFee; + uint effectiveFee = FeeFor(before, walletHolder.ClassicAddress); + + const decimal Withdraw = 100m; + + decimal estimated = AmmMath.LPTokensForSingleAssetWithdraw( + poolBalance, + Withdraw, + lpBefore, + effectiveFee); + + AMMWithdraw withdraw = new AMMWithdraw + { + Account = walletHolder.ClassicAddress, + Asset = TokenAsset, + Asset2 = XrpAsset, + Amount = new Currency + { + CurrencyCode = CurrencyCode, + Issuer = walletIssuer.ClassicAddress, + Value = Withdraw.ToString(System.Globalization.CultureInfo.InvariantCulture), + }, + Flags = AMMWithdrawFlags.tfSingleAsset, + }; + + ITransactionRequest autofilled = await client.Autofill(withdraw); + TransactionSummary result = await client.SubmitAndWait(autofilled, walletHolder, true); + AssertSuccess(result, "AMMWithdraw single asset"); + + AMMInfoResponse after = await GetAmmInfo(); + decimal spent = lpBefore - after.Amm.LPTokenBalance.ValueAsNumber; + + decimal relativeError = Math.Abs(spent - estimated) / spent; + + Console.WriteLine( + $"pool {poolBalance}, pool fee {poolFee}, effective fee {effectiveFee}, withdraw {Withdraw}: " + + $"estimated {estimated}, spent {spent}, relative error {relativeError}"); + + Assert.IsTrue( + relativeError < 0.000000001m, + $"Estimated {estimated} against {spent} actually spent - a relative error of " + + $"{relativeError}, against a bound of 1e-9."); + } + + /// + /// Equation 4: asking the node for an exact number of LP tokens costs what it says it costs. + /// + /// + /// The other direction of the deposit, and the one the unit tests can only reach through an + /// identity. tfOneAssetLPToken names the tokens wanted and lets the node work out the + /// asset, with Amount as a ceiling rather than the figure - so what is compared here is + /// what the node decided to take. + /// + [TestMethod] + public async Task TestIADepositForExactTokensCostsAsEstimated() + { + await CreatePool(); + + AMMInfoResponse before = await GetAmmInfo(); + decimal poolBalance = before.Amm.Amount.ValueAsNumber; + decimal lpBefore = before.Amm.LPTokenBalance.ValueAsNumber; + uint effectiveFee = FeeFor(before, walletHolder.ClassicAddress); + + const decimal WantTokens = 50m; + + decimal estimated = AmmMath.SingleAssetDepositForLPTokens( + poolBalance, + WantTokens, + lpBefore, + effectiveFee); + + AMMDeposit deposit = new AMMDeposit + { + Account = walletHolder.ClassicAddress, + Asset = TokenAsset, + Asset2 = XrpAsset, + + // A ceiling, deliberately far above the estimate: if the node were to spend all of it + // the comparison below would fail loudly instead of being satisfied by construction. + Amount = new Currency + { + CurrencyCode = CurrencyCode, + Issuer = walletIssuer.ClassicAddress, + Value = "500", + }, + LPTokenOut = LpTokens(before, WantTokens), + Flags = AMMDepositFlags.tfOneAssetLPToken, + }; + + ITransactionRequest autofilled = await client.Autofill(deposit); + TransactionSummary result = await client.SubmitAndWait(autofilled, walletHolder, true); + AssertSuccess(result, "AMMDeposit one asset for LP tokens"); + + AMMInfoResponse after = await GetAmmInfo(); + decimal taken = after.Amm.Amount.ValueAsNumber - poolBalance; + decimal credited = after.Amm.LPTokenBalance.ValueAsNumber - lpBefore; + + decimal relativeError = Math.Abs(taken - estimated) / taken; + + Console.WriteLine( + $"pool {poolBalance}, effective fee {effectiveFee}, wanted {WantTokens} tokens: " + + $"estimated {estimated}, taken {taken}, credited {credited}, relative error {relativeError}"); + + Assert.AreEqual( + WantTokens, + credited, + "tfOneAssetLPToken credits exactly what was asked for; if it did not, the comparison below is measuring something else."); + + Assert.IsTrue( + relativeError < 0.000000001m, + $"Estimated a cost of {estimated} against {taken} actually taken - a relative error of " + + $"{relativeError}, against a bound of 1e-9."); + } + + /// + /// Equation 8: redeeming an exact number of LP tokens returns what it says it returns. + /// + /// + /// Amount is a floor here rather than a ceiling, so it is set low enough not to bind + /// and the node's own figure is what gets compared. + /// + [TestMethod] + public async Task TestIAWithdrawForExactTokensReturnsAsEstimated() + { + await CreatePool(); + + AMMInfoResponse before = await GetAmmInfo(); + decimal poolBalance = before.Amm.Amount.ValueAsNumber; + decimal lpBefore = before.Amm.LPTokenBalance.ValueAsNumber; + uint effectiveFee = FeeFor(before, walletHolder.ClassicAddress); + + const decimal RedeemTokens = 50m; + + decimal estimated = AmmMath.SingleAssetWithdrawForLPTokens( + poolBalance, + RedeemTokens, + lpBefore, + effectiveFee); + + AMMWithdraw withdraw = new AMMWithdraw + { + Account = walletHolder.ClassicAddress, + Asset = TokenAsset, + Asset2 = XrpAsset, + Amount = new Currency + { + CurrencyCode = CurrencyCode, + Issuer = walletIssuer.ClassicAddress, + Value = "0.000001", + }, + LPTokenIn = LpTokens(before, RedeemTokens), + Flags = AMMWithdrawFlags.tfOneAssetLPToken, + }; + + ITransactionRequest autofilled = await client.Autofill(withdraw); + TransactionSummary result = await client.SubmitAndWait(autofilled, walletHolder, true); + AssertSuccess(result, "AMMWithdraw one asset for LP tokens"); + + AMMInfoResponse after = await GetAmmInfo(); + decimal received = poolBalance - after.Amm.Amount.ValueAsNumber; + decimal spent = lpBefore - after.Amm.LPTokenBalance.ValueAsNumber; + + decimal relativeError = Math.Abs(received - estimated) / received; + + Console.WriteLine( + $"pool {poolBalance}, effective fee {effectiveFee}, redeemed {RedeemTokens} tokens: " + + $"estimated {estimated}, received {received}, spent {spent}, relative error {relativeError}"); + + Assert.AreEqual(RedeemTokens, spent, "The node should burn exactly the tokens it was given."); + + Assert.IsTrue( + relativeError < 0.000000001m, + $"Estimated {estimated} against {received} actually returned - a relative error of " + + $"{relativeError}, against a bound of 1e-9."); + } + + /// + /// The swap: a payment routed through the pool pays out what + /// says it will. + /// + /// + /// + /// Nobody swaps by sending an AMMDeposit; a swap reaches the pool as a payment, which + /// is why this one goes through Payment rather than an AMM transaction. With + /// tfPartialPayment and a destination amount the pool cannot possibly cover, the whole + /// of SendMax goes in and whatever the curve gives comes out - which is exactly the + /// quantity the formula computes. + /// + /// + /// The account swapping here is a second holder, not the one that created the pool, so this + /// is the one case in this class that trades at the pool's own fee rather than at the auction + /// slot's discount. The other tests cover the discounted path; between them both branches of + /// are exercised against a node. + /// + /// + /// The arithmetic below is in drops, because that is the unit amm_info reports the XRP + /// side of a pool in. converts nothing, so mixing drops with XRP in one + /// call produces a number that looks like a broken formula rather than a unit mistake - the + /// first draft of this test did exactly that. + /// + /// + [TestMethod] + public async Task TestIASwapThroughThePoolPaysOutAsEstimated() + { + await CreatePool(); + XrplWallet swapper = await SetupSecondHolder(); + + AMMInfoResponse before = await GetAmmInfo(); + decimal poolToken = before.Amm.Amount.ValueAsNumber; + decimal poolXrpDrops = before.Amm.Amount2.ValueAsNumber; + uint effectiveFee = FeeFor(before, swapper.ClassicAddress); + + Assert.AreEqual( + before.Amm.TradingFee, + effectiveFee, + "The swapper does not hold the auction slot, so this must be the pool's own fee."); + + const decimal SendXrp = 1m; + const decimal SendDrops = SendXrp * 1_000_000m; + + // The pool takes XRP and gives the token back. + decimal estimated = AmmMath.SwapAssetIn(poolXrpDrops, poolToken, SendDrops, effectiveFee); + + Payment payment = new Payment + { + Account = swapper.ClassicAddress, + Destination = walletHolder.ClassicAddress, + + // Far more than one XRP can buy, so SendMax is what binds and the whole of it is spent. + Amount = new Currency + { + CurrencyCode = CurrencyCode, + Issuer = walletIssuer.ClassicAddress, + Value = "1000", + }, + SendMax = new Currency { ValueAsXrp = SendXrp }, + DeliverMin = new Currency + { + CurrencyCode = CurrencyCode, + Issuer = walletIssuer.ClassicAddress, + Value = "0.000001", + }, + Flags = PaymentFlags.tfPartialPayment, + }; + + ITransactionRequest autofilled = await client.Autofill(payment); + TransactionSummary result = await client.SubmitAndWait(autofilled, swapper, true); + AssertSuccess(result, "Payment routed through the AMM"); + + AMMInfoResponse after = await GetAmmInfo(); + decimal spentDrops = after.Amm.Amount2.ValueAsNumber - poolXrpDrops; + decimal received = poolToken - after.Amm.Amount.ValueAsNumber; + + decimal relativeError = Math.Abs(received - estimated) / received; + + Console.WriteLine( + $"pool {poolToken} token / {poolXrpDrops} drops, fee {effectiveFee}, sent {spentDrops} drops: " + + $"estimated {estimated}, paid out {received}, relative error {relativeError}"); + + Assert.AreEqual( + SendDrops, + spentDrops, + "The whole of SendMax should have entered the pool; if it did not, this is measuring a smaller swap than it estimated."); + + Assert.IsTrue( + relativeError < 0.000000001m, + $"Estimated {estimated} out of the pool against {received} actually paid - a relative " + + $"error of {relativeError}, against a bound of 1e-9."); + } + + /// + /// The pool's LP token, as an amount this many of them. + /// + /// + /// The currency code is a hash of the two assets and the issuer is the AMM's own account, so + /// both are read off amm_info rather than constructed. + /// + private static Currency LpTokens(AMMInfoResponse info, decimal count) => new Currency + { + CurrencyCode = info.Amm.LPTokenBalance.CurrencyCode, + Issuer = info.Amm.LPTokenBalance.Issuer, + Value = count.ToString(System.Globalization.CultureInfo.InvariantCulture), + }; + + /// + /// The fee this account actually trades at, which is not the pool's fee if it holds the slot. + /// + /// + /// What a consumer has to do too, and the reason + /// exists. The node's own discounted_fee is used rather than computed, and checked + /// against what the SDK would have computed - if those ever disagree, the SDK's constant is + /// wrong and this says so. + /// + private static uint FeeFor(AMMInfoResponse info, string account) + { + AuctionSlot slot = info.Amm.AuctionSlot; + if (slot is null || !string.Equals(slot.Account, account, StringComparison.Ordinal)) + { + return info.Amm.TradingFee; + } + + Assert.AreEqual( + slot.DiscountedFee, + AmmMath.DiscountedTradingFee(info.Amm.TradingFee), + "The SDK's idea of the auction slot discount must be the node's."); + + return slot.DiscountedFee; + } +} diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIBatch.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIBatch.cs index 619dce78..e1f3b963 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIBatch.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIBatch.cs @@ -15,6 +15,7 @@ using Xrpl.Sugar; using Xrpl.Wallet; +using Xrpl.Client; namespace XrplTests.Xrpl.ClientLib.Integration; [TestClass] @@ -121,7 +122,7 @@ private static async Task GetTxForSingleMultiSign() Console.WriteLine("NEXT"); var request = new AccountInfoRequest(owner.ClassicAddress); - var accountInfo = await runner.client.AccountInfo(request); + var accountInfo = await runner.client.AccountInfo(request).Typed(); //var flags = BatchGlobalFlags.tfInnerBatchTxn; // Внутренний Payment #1 @@ -557,7 +558,7 @@ private void ValidateResult(TransactionSummary res) private static async Task SetSigners(XrplWallet owner, XrplWallet signer1, XrplWallet signer2) { - var acc = await runner.client.AccountInfo(new AccountInfoRequest(owner.ClassicAddress){SignerLists = true}); + var acc = await runner.client.AccountInfo(new AccountInfoRequest(owner.ClassicAddress){SignerLists = true}).Typed(); if (acc.SignerLists is { Length: > 0 }) { return true; @@ -586,7 +587,7 @@ private static async Task SetSigners(XrplWallet owner, XrplWallet signer1, private static async Task DisableMaster(XrplWallet owner) { - var acc = await runner.client.AccountInfo(new AccountInfoRequest(owner.ClassicAddress)); + var acc = await runner.client.AccountInfo(new AccountInfoRequest(owner.ClassicAddress)).Typed(); if (acc.AccountFlags.DisableMasterKey) { return true; diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs index 50bbbcf7..794ee3f7 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs @@ -157,7 +157,7 @@ public async Task Batch_SponsoredReserveInner_SponsorAsBatchSigner() for (int attempt = 0; attempt < 10 && line is null; attempt++) { await Task.Delay(2000); - AccountObjects objects = await client.AccountObjects(request); + AccountObjects objects = await client.AccountObjects(request).Typed(); line = objects?.AccountObjectList?.OfType().FirstOrDefault(); } Assert.IsNotNull(line, "the sponsored trust line must appear in the ledger"); @@ -256,7 +256,7 @@ public async Task Batch_SponsoredReserveInner_SponsorSignsViaSignerList() for (int attempt = 0; attempt < 10 && line is null; attempt++) { await Task.Delay(2000); - AccountObjects objects = await client.AccountObjects(request); + AccountObjects objects = await client.AccountObjects(request).Typed(); line = objects?.AccountObjectList?.OfType().FirstOrDefault(); } Assert.IsNotNull(line, "the sponsored trust line must appear in the ledger"); diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIClawback.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIClawback.cs index b2358a68..4e95ea1f 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIClawback.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIClawback.cs @@ -189,7 +189,7 @@ private static async Task GetHolderBalance() { Peer = walletIssuer.ClassicAddress }; - var response = await client.AccountLines(request); + var response = await client.AccountLines(request).Typed(); if (response?.TrustLines == null) return 0; diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestICredential.cs b/Tests/Xrpl.Tests/Integration/transactions/TestICredential.cs index 5ae9b425..42cdae5f 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestICredential.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestICredential.cs @@ -376,7 +376,7 @@ public async Task TestCredential_EndToEnd_DepositPreauthAndPayment() DestinationAccount = walletRecipient.ClassicAddress, Credentials = new List { credentialId }, }; - DepositAuthorized depAuthResp = await client.DepositAuthorized(depAuthReq); + DepositAuthorized depAuthResp = await client.DepositAuthorized(depAuthReq).Typed(); Assert.IsNotNull(depAuthResp, "deposit_authorized response is null"); Assert.IsTrue(depAuthResp.IsDepositAuthorized, "deposit_authorized must be true with valid credentials"); Console.WriteLine("Step 5: deposit_authorized confirmed"); @@ -494,7 +494,7 @@ private async Task VerifyCredentialExists(string issuer, string subject, s LedgerIndex = new LedgerIndex(LedgerIndexType.Validated), Type = LedgerEntryType.Credential, }; - var response = await client.AccountObjects(request); + var response = await client.AccountObjects(request).Typed(); if (response?.AccountObjectList != null) { foreach (var obj in response.AccountObjectList) diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIDID.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIDID.cs index bc3731b2..cebf54d1 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIDID.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIDID.cs @@ -86,7 +86,7 @@ private async Task VerifyDIDExists(string account) { DID = account }; - var response = await client.LedgerEntry(request); + var response = await client.LedgerEntry(request).Typed(); return response?.Node != null; } catch diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIDelegateSet.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIDelegateSet.cs index 00f308a4..626f32ca 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIDelegateSet.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIDelegateSet.cs @@ -61,7 +61,7 @@ private static async Task GetDelegateObject(string ownerAddress) { Type = LedgerEntryType.Delegate, }; - AccountObjects response = await client.AccountObjects(request); + AccountObjects response = await client.AccountObjects(request).Typed(); return response?.AccountObjectList? .OfType() @@ -179,7 +179,7 @@ public async Task TestDelegatedPayment_DelegateFieldSurvivesTheLedgerRoundTrip() ValidateResult(result); // Back out of the ledger into the typed model - TransactionResponse readBack = await client.Tx(new TxRequest(result.Hash)); + TransactionResponse readBack = await client.TxV1(new TxRequest(result.Hash)).Typed(); Assert.AreEqual(walletDelegate.ClassicAddress, readBack.Delegate, "Delegate must survive the ledger round trip"); Assert.AreEqual(walletOwner.ClassicAddress, readBack.Account, "the transaction stays the owner's"); diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIDynamicMPT.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIDynamicMPT.cs index e0e1c888..a7412f6b 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIDynamicMPT.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIDynamicMPT.cs @@ -239,7 +239,7 @@ private static async Task CreateIssuance( private static async Task ReadIssuance(string issuanceId) { LedgerEntryRequest request = new LedgerEntryRequest { MptIssuance = issuanceId }; - LedgerEntryResponse response = await client.LedgerEntry(request); + LedgerEntryResponse response = await client.LedgerEntry(request).Typed(); Assert.IsNotNull(response?.Node, "ledger_entry should return the MPTokenIssuance node"); Assert.IsInstanceOfType(response.Node, typeof(LOMPTokenIssuance), "Node should deserialize to LOMPTokenIssuance"); diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIEscrow.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIEscrow.cs index 7983addc..9428ec73 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIEscrow.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIEscrow.cs @@ -40,6 +40,11 @@ public class TestIEscrow /// and which the cancel tests wait out in full. /// private static readonly TimeSpan CancelAfterMargin = TimeSpan.FromSeconds(24); + + /// + /// How long sleeps between polls. + /// + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(3); //static XrplWallet walletIssuer = XrplWallet.Generate(); //static XrplWallet walletHolder1 = XrplWallet.Generate(); @@ -68,7 +73,7 @@ public async Task TestXrpEscrowCreate_AndFinish() await IntegrationTestConfig.TryFundWalletAsync(client, walletHolder2, nodeType); LedgerRequest ledgerReq = new LedgerRequest() { LedgerIndex = new LedgerIndex(LedgerIndexType.Validated) }; - LOLedger ledgerResponse = await client.Ledger(ledgerReq); + LOLedger ledgerResponse = await client.Ledger(ledgerReq).Typed(); LedgerEntity ledgerEntity = (LedgerEntity)ledgerResponse.LedgerEntity; var closeTime = ledgerEntity.CloseTime; @@ -85,7 +90,7 @@ public async Task TestXrpEscrowCreate_AndFinish() ValidateResult(escrowCreateResult); AccountObjectsRequest objReq = new AccountObjectsRequest(walletHolder1.ClassicAddress) { Type = LedgerEntryType.Escrow }; - AccountObjects objResp = await client.AccountObjects(objReq); + AccountObjects objResp = await client.AccountObjects(objReq).Typed(); Assert.IsTrue(objResp.AccountObjectList.Count >= 1, "At least one escrow should exist after creation"); await WaitForLedgerCloseTime(client, closeTime.Value + FinishAfterMargin); @@ -112,7 +117,7 @@ public async Task TestXrpEscrowCreate_AndCancel() await IntegrationTestConfig.TryFundWalletAsync(client, walletHolder2, nodeType); LedgerRequest ledgerReq = new LedgerRequest() { LedgerIndex = new LedgerIndex(LedgerIndexType.Validated) }; - LOLedger ledgerResponse = await client.Ledger(ledgerReq); + LOLedger ledgerResponse = await client.Ledger(ledgerReq).Typed(); LedgerEntity ledgerEntity = (LedgerEntity)ledgerResponse.LedgerEntity; var closeTime = ledgerEntity.CloseTime; @@ -131,7 +136,7 @@ public async Task TestXrpEscrowCreate_AndCancel() ValidateResult(escrowCreateResult); AccountObjectsRequest objReq = new AccountObjectsRequest(walletHolder1.ClassicAddress) { Type = LedgerEntryType.Escrow }; - AccountObjects objResp = await client.AccountObjects(objReq); + AccountObjects objResp = await client.AccountObjects(objReq).Typed(); Assert.IsTrue(objResp.AccountObjectList.Count >= 1, "At least one escrow should exist after creation"); await WaitForLedgerCloseTime(client, cancelAfterTime.Value); @@ -199,7 +204,7 @@ public async Task TestIOUEscrowCreate_AndFinish() ValidateResult(trustResult2); LedgerRequest ledgerReq = new LedgerRequest() { LedgerIndex = new LedgerIndex(LedgerIndexType.Validated) }; - LOLedger ledgerResponse = await client.Ledger(ledgerReq); + LOLedger ledgerResponse = await client.Ledger(ledgerReq).Typed(); LedgerEntity ledgerEntity = (LedgerEntity)ledgerResponse.LedgerEntity; var closeTime = ledgerEntity.CloseTime; @@ -216,7 +221,7 @@ public async Task TestIOUEscrowCreate_AndFinish() ValidateResult(escrowCreateResult); AccountObjectsRequest objReq = new AccountObjectsRequest(walletHolder1.ClassicAddress) { Type = LedgerEntryType.Escrow }; - AccountObjects objResp = await client.AccountObjects(objReq); + AccountObjects objResp = await client.AccountObjects(objReq).Typed(); Assert.IsTrue(objResp.AccountObjectList.Count >= 1, "At least one escrow should exist after creation"); await WaitForLedgerCloseTime(client, closeTime.Value + FinishAfterMargin); @@ -280,7 +285,7 @@ public async Task TestIOUEscrowCreate_AndCancel() ValidateResult(trustResult2); LedgerRequest ledgerReq = new LedgerRequest() { LedgerIndex = new LedgerIndex(LedgerIndexType.Validated) }; - LOLedger ledgerResponse = await client.Ledger(ledgerReq); + LOLedger ledgerResponse = await client.Ledger(ledgerReq).Typed(); LedgerEntity ledgerEntity = (LedgerEntity)ledgerResponse.LedgerEntity; var closeTime = ledgerEntity.CloseTime; @@ -299,7 +304,7 @@ public async Task TestIOUEscrowCreate_AndCancel() ValidateResult(escrowCreateResult); AccountObjectsRequest objReq = new AccountObjectsRequest(walletHolder1.ClassicAddress) { Type = LedgerEntryType.Escrow }; - AccountObjects objResp = await client.AccountObjects(objReq); + AccountObjects objResp = await client.AccountObjects(objReq).Typed(); Assert.IsTrue(objResp.AccountObjectList.Count >= 1, "At least one escrow should exist after creation"); await WaitForLedgerCloseTime(client, cancelAfterTime.Value); @@ -379,7 +384,7 @@ public async Task TestMPTEscrowCreate_AndFinish() ValidateResult(authResult2); LedgerRequest ledgerReq = new LedgerRequest() { LedgerIndex = new LedgerIndex(LedgerIndexType.Validated) }; - LOLedger ledgerResponse = await client.Ledger(ledgerReq); + LOLedger ledgerResponse = await client.Ledger(ledgerReq).Typed(); LedgerEntity ledgerEntity = (LedgerEntity)ledgerResponse.LedgerEntity; var closeTime = ledgerEntity.CloseTime; @@ -397,7 +402,7 @@ public async Task TestMPTEscrowCreate_AndFinish() ValidateResult(escrowCreateResult); AccountObjectsRequest objReq = new AccountObjectsRequest(walletHolder1.ClassicAddress) { Type = LedgerEntryType.Escrow }; - AccountObjects objResp = await client.AccountObjects(objReq); + AccountObjects objResp = await client.AccountObjects(objReq).Typed(); Assert.IsTrue(objResp.AccountObjectList.Count >= 1, "At least one escrow should exist after creation"); await WaitForLedgerCloseTime(client, closeTime.Value + FinishAfterMargin); @@ -465,7 +470,7 @@ public async Task TestMPTEscrowCreate_AndCancel() ValidateResult(payResult); LedgerRequest ledgerReq = new LedgerRequest() { LedgerIndex = new LedgerIndex(LedgerIndexType.Validated) }; - LOLedger ledgerResponse = await client.Ledger(ledgerReq); + LOLedger ledgerResponse = await client.Ledger(ledgerReq).Typed(); LedgerEntity ledgerEntity = (LedgerEntity)ledgerResponse.LedgerEntity; var closeTime = ledgerEntity.CloseTime; @@ -484,7 +489,7 @@ public async Task TestMPTEscrowCreate_AndCancel() ValidateResult(escrowCreateResult); AccountObjectsRequest objReq = new AccountObjectsRequest(walletHolder1.ClassicAddress) { Type = LedgerEntryType.Escrow }; - AccountObjects objResp = await client.AccountObjects(objReq); + AccountObjects objResp = await client.AccountObjects(objReq).Typed(); Assert.IsTrue(objResp.AccountObjectList.Count >= 1, "At least one escrow should exist after creation"); await WaitForLedgerCloseTime(client, cancelAfterTime.Value); @@ -504,19 +509,83 @@ public async Task TestMPTEscrowCreate_AndCancel() #region Helper Methods + /// + /// Waits until the validated ledger's close time passes . + /// + /// + /// Close time on the standalone stand tracks the wall clock almost second for second, in steps + /// that round up to multiples of ten - measured, not assumed: over 94 seconds of real time it + /// advanced 92 seconds, in a repeating +1, +1, +8 pattern. So a target one margin ahead is + /// reached in about that margin plus one rounding step, and a budget of a minute is generous + /// for the 24-second margin these tests use. + /// + /// When it nonetheless runs out, the reason matters and the old message did not carry it: it + /// named the target and nothing else, so a failure could not be told apart from a close time + /// that never arrived, one that arrived null, or a node that stopped answering. It also + /// reported a number of seconds that was not the one it waited - the loop ran + /// maxWaitSeconds + 10. Both fixed here; what the failure says is now enough to act on. + /// + /// private static async Task WaitForLedgerCloseTime(IXrplClient client, DateTime targetTime, int maxWaitSeconds = 60) { var sw = System.Diagnostics.Stopwatch.StartNew(); - while (sw.Elapsed.TotalSeconds < maxWaitSeconds+10) - { - await Task.Delay(3000); - LedgerRequest req = new LedgerRequest() { LedgerIndex = new LedgerIndex(LedgerIndexType.Validated) }; - LOLedger resp = await client.Ledger(req); - LedgerEntity entity = (LedgerEntity)resp.LedgerEntity; - if (entity.CloseTime > targetTime) - return; + DateTime? lastCloseTime = null; + int polls = 0; + string lastError = null; + string acceptError = null; + + while (true) + { + // The budget is a budget, not a starting gun: checking it before a fixed delay let a + // poll begin just under the limit and then run its requests past it, so the helper + // could overrun the number it reports. What is left decides both whether to wait and + // for how long. + TimeSpan remaining = TimeSpan.FromSeconds(maxWaitSeconds) - sw.Elapsed; + if (remaining <= TimeSpan.Zero) + { + break; + } + + await Task.Delay(remaining < PollInterval ? remaining : PollInterval); + polls++; + + try + { + // Closing the ledger is an optimisation, not the measurement, and its failure must + // not cost the poll. Inside the same try it did: an unavailable or unauthorised + // ledger_accept threw past the query below, so the helper stopped looking at the + // chain entirely and timed out even while someone else kept closing ledgers - the + // very fallback the comment below claimed to preserve. + await client.AnyRequest(new BaseRequest { Command = "ledger_accept" }); + } + catch (Exception error) + { + acceptError = $"{error.GetType().Name}: {error.Message}"; + } + + try + { + LedgerRequest req = new LedgerRequest() { LedgerIndex = new LedgerIndex(LedgerIndexType.Validated) }; + LOLedger resp = await client.Ledger(req).Typed(); + LedgerEntity entity = (LedgerEntity)resp.LedgerEntity; + lastCloseTime = entity.CloseTime; + if (lastCloseTime > targetTime) + return; + } + catch (Exception error) + { + // Kept and reported rather than thrown: one refused round trip under load should + // not end the wait, but a wait that ended having only ever seen errors must say so. + lastError = $"{error.GetType().Name}: {error.Message}"; + } } - Assert.Fail($"Ledger close time did not exceed {targetTime} within {maxWaitSeconds} seconds"); + + Assert.Fail( + $"Ledger close time did not pass {targetTime:O} within {maxWaitSeconds}s. " + + $"Last close time seen: {(lastCloseTime.HasValue ? lastCloseTime.Value.ToString("O") : "never read")}" + + $"{(lastCloseTime.HasValue ? $" (short by {(targetTime - lastCloseTime.Value).TotalSeconds:F1}s)" : string.Empty)}, " + + $"polls: {polls}, last read error: {lastError ?? "none"}, " + + $"last ledger_accept error: {acceptError ?? "none"}"); } private static void ValidateResult(Submit res) diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestILoan.cs b/Tests/Xrpl.Tests/Integration/transactions/TestILoan.cs index 63f57c37..157eab62 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestILoan.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestILoan.cs @@ -270,7 +270,7 @@ public async Task TestLoanBrokerLedgerEntry_VerifyFields() // Fetch LoanBroker via ledger_entry LedgerEntryRequest entryRequest = new LedgerEntryRequest { Index = brokerId }; - LedgerEntryResponse entryResponse = await client.LedgerEntry(entryRequest); + LedgerEntryResponse entryResponse = await client.LedgerEntry(entryRequest).Typed(); Assert.IsNotNull(entryResponse?.Node, "LedgerEntry node should not be null"); Assert.IsInstanceOfType(entryResponse.Node, typeof(LOLoanBroker), "Node should deserialize to LOLoanBroker"); @@ -324,7 +324,7 @@ public async Task TestLoanLedgerEntry_VerifyFields() // Fetch Loan via ledger_entry LedgerEntryRequest entryRequest = new LedgerEntryRequest { Index = loanId }; - LedgerEntryResponse entryResponse = await client.LedgerEntry(entryRequest); + LedgerEntryResponse entryResponse = await client.LedgerEntry(entryRequest).Typed(); Assert.IsNotNull(entryResponse?.Node, "LedgerEntry node should not be null"); Assert.IsInstanceOfType(entryResponse.Node, typeof(LOLoan), "Node should deserialize to LOLoan"); diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs b/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs index 757881f1..c4cd47f8 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs @@ -241,7 +241,7 @@ protected static async Task SubmitSignedLoanSet(IXrplClient await Task.Delay(1000); try { - txResponse = await client.Tx(txReq); + txResponse = await client.TxV1(txReq).Typed(); if (txResponse?.Meta != null) break; } catch diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIMPTokenAuthorize.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIMPTokenAuthorize.cs index 3f8855eb..209da022 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIMPTokenAuthorize.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIMPTokenAuthorize.cs @@ -243,7 +243,7 @@ public async Task TestMPTPayment_TransferAndVerifyBalance() MPTokenIssuanceID = issuanceId, }, }; - var ledgerEntryResponse = await client.LedgerEntry(ledgerEntryRequest); + var ledgerEntryResponse = await client.LedgerEntry(ledgerEntryRequest).Typed(); Assert.IsNotNull(ledgerEntryResponse, "LedgerEntry response should not be null"); Assert.IsNotNull(ledgerEntryResponse.Node, "LedgerEntry node should not be null"); diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIMemoLimits.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIMemoLimits.cs new file mode 100644 index 00000000..2abf3b9d --- /dev/null +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIMemoLimits.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Client; +using Xrpl.Models.Methods; +using Xrpl.Models.Transactions; +using Xrpl.Sugar; +using Xrpl.Wallet; + +namespace XrplTests.Xrpl.ClientLib.Integration; + +/// +/// The memo limit the SDK refuses before signing is the one a node actually applies - issue #119. +/// +/// +/// +/// The constant comes from reading rippled's isMemoOkay, and a constant read off a source +/// file can be wrong in two directions that a unit test cannot tell apart. Too strict and the SDK +/// refuses transactions a node would have taken; too loose and the refusal it exists to prevent +/// happens anyway. Both directions are checked here against a real node. +/// +/// +/// The second test signs on the node rather than in the SDK, using submit with a secret. +/// That is deliberate: the SDK's own check would stop the transaction first, and what has to be +/// observed is the node refusing it, in its own words. +/// +/// +[TestClass] +public class TestIMemoLimits +{ + private static IXrplClient client; + private static XrplWallet wallet; + + /// + /// The largest MemoData that fits, per . + /// + private const int LargestMemoDataInOneMemo = 1019; + + [ClassInitialize] + public static async Task ClassInitializeAsync(TestContext testContext) + { + client = await IntegrationTestConfig.CreateClientAsync(TestNodeType.Standalone); + wallet = await Utils.GenerateFundedWallet(client); + } + + [ClassCleanup] + public static void ClassCleanup() => client?.Dispose(); + + private static Dictionary PaymentWithMemo(int memoDataBytes) => new Dictionary + { + { "TransactionType", "Payment" }, + { "Account", wallet.ClassicAddress }, + { "Destination", "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh" }, + { "Amount", "1000" }, + { + "Memos", new List + { + new Dictionary + { + { + "Memo", new Dictionary + { + { "MemoData", new string('A', memoDataBytes * 2) }, + } + }, + }, + } + }, + }; + + /// + /// A memo filling the limit exactly is accepted and reaches a ledger, so the SDK is not + /// refusing what a node would take. + /// + [TestMethod] + public async Task TestIMemoAtTheLimitIsAccepted() + { + Dictionary tx = PaymentWithMemo(LargestMemoDataInOneMemo); + + Submit response = await client.Submit(tx, wallet: wallet); + + Assert.AreEqual( + "tesSUCCESS", + response.EngineResult, + $"A memo of exactly {MemoRules.MaxSerializedLength} serialized bytes must be accepted: " + + $"refusing it locally would cost consumers transactions the node would have taken. " + + $"Node said: {response.EngineResultMessage}"); + } + + /// + /// One byte over, and the node refuses it - which is what the local check exists to save the + /// caller from discovering after signing. + /// + [TestMethod] + public async Task TestIMemoOverTheLimitIsRefusedByTheNode() + { + Dictionary tx = PaymentWithMemo(LargestMemoDataInOneMemo + 1); + tx["Fee"] = "12"; + AccountInfo account = (await client.AccountInfo(new AccountInfoRequest(wallet.ClassicAddress))).Result; + tx["Sequence"] = account.AccountData.Sequence; + + // Signed by the node, not by us: our own rules would refuse this before a signature + // existed, and the point here is to hear the node's answer. + Dictionary request = new Dictionary + { + { "command", "submit" }, + { "tx_json", tx }, + { "secret", wallet.Seed }, + }; + + string answer; + try + { + Dictionary response = await client.Request(request).Typed(); + answer = string.Join(", ", response.Keys) + " => " + string.Join(", ", response.Values); + } + catch (Exception error) + { + answer = error.Message; + } + + // The node's own words, not merely the string "memo": the answer echoes the request, whose + // tx_json carries a Memos field, so a looser assertion would pass without the node having + // objected to anything. Observed here: "invalidParams - The memo exceeds the maximum + // allowed size." + StringAssert.Contains( + answer, + "exceeds the maximum allowed size", + $"The node must refuse a memo one byte past the limit, and say why. It answered: {answer}"); + } +} diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIMultisign.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIMultisign.cs index 793c0bdf..c81bf342 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIMultisign.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIMultisign.cs @@ -12,6 +12,7 @@ using Xrpl.Sugar; using Xrpl.Wallet; +using Xrpl.Client; namespace XrplTests.Xrpl.ClientLib.Integration; [TestClass] @@ -105,7 +106,7 @@ private static async Task GetPaymentForMultiSign() var owner = walletMultiSign; var request = new AccountInfoRequest(owner.ClassicAddress); - var accountInfo = await runner.client.AccountInfo(request); + var accountInfo = await runner.client.AccountInfo(request).Typed(); var payment = new Payment { @@ -151,7 +152,7 @@ private static void ValidateResult(Submit res) private static async Task SetSigners(XrplWallet owner, XrplWallet signer1, XrplWallet signer2) { - var acc = await runner.client.AccountInfo(new AccountInfoRequest(owner.ClassicAddress) { SignerLists = true }); + var acc = await runner.client.AccountInfo(new AccountInfoRequest(owner.ClassicAddress) { SignerLists = true }).Typed(); if (acc.SignerLists is { Length: > 0 }) { return true; @@ -180,7 +181,7 @@ private static async Task SetSigners(XrplWallet owner, XrplWallet signer1, private static async Task DisableMaster(XrplWallet owner) { - var acc = await runner.client.AccountInfo(new AccountInfoRequest(owner.ClassicAddress)); + var acc = await runner.client.AccountInfo(new AccountInfoRequest(owner.ClassicAddress)).Typed(); if (acc.AccountFlags.DisableMasterKey) { return true; diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIOracle.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIOracle.cs index 0608d853..010f9f88 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIOracle.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIOracle.cs @@ -162,7 +162,7 @@ public async Task TestOracleSet_ReadBack_FieldValuesRoundTrip() { Type = LedgerEntryType.Oracle, }; - var response = await client.AccountObjects(request); + var response = await client.AccountObjects(request).Typed(); var oracle = response?.AccountObjectList?.OfType().FirstOrDefault(); Assert.IsNotNull(oracle, "the Oracle object must deserialize from the node response"); @@ -530,7 +530,7 @@ public async Task TestOracle_FullLifecycle() private static async Task GetLedgerCloseTimeAsync() { var ledgerRequest = new LedgerRequest { LedgerIndex = new LedgerIndex(LedgerIndexType.Validated) }; - var ledgerResponse = await client.Ledger(ledgerRequest); + var ledgerResponse = await client.Ledger(ledgerRequest).Typed(); var ledgerEntity = ledgerResponse.LedgerEntity as LedgerEntity; var closeTime = ledgerEntity?.CloseTime ?? DateTime.UtcNow; Console.WriteLine($"Ledger close_time: {closeTime:u}"); @@ -566,7 +566,7 @@ private static async Task VerifyOracleExists(string account, uint oracleDo AccountObjects response; try { - response = await client.AccountObjects(request); + response = await client.AccountObjects(request).Typed(); } catch (Exception ex) { diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIProtocolFieldSets.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIProtocolFieldSets.cs index dd63b0ad..90402a21 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIProtocolFieldSets.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIProtocolFieldSets.cs @@ -70,7 +70,7 @@ public async Task TestICheckCreate_OptionalFieldsLandOnTheLedger() await Utils.LedgerAccept(client); AccountObjects objects = await client.AccountObjects( - new AccountObjectsRequest(sender.ClassicAddress) { Type = LedgerEntryType.Check }); + new AccountObjectsRequest(sender.ClassicAddress) { Type = LedgerEntryType.Check }).Typed(); LOCheck check = objects.AccountObjectList.OfType().Single(); Assert.AreEqual(invoiceId, check.InvoiceID, "InvoiceID must survive as a Hash256"); @@ -79,7 +79,7 @@ public async Task TestICheckCreate_OptionalFieldsLandOnTheLedger() // ...and back into the typed transaction model, not just the ledger object: // InvoiceID was uint? until this change and could not round-trip at all - CheckCreateResponse readBack = await client.Tx(new TxRequest(hash)) as CheckCreateResponse; + CheckCreateResponse readBack = await client.TxV1(new TxRequest(hash)).Typed() as CheckCreateResponse; Assert.IsNotNull(readBack, "tx must deserialize into CheckCreateResponse"); Assert.AreEqual(invoiceId, readBack.InvoiceID); Assert.AreEqual(13u, readBack.DestinationTag); @@ -111,7 +111,7 @@ public async Task TestICheckCash_DeliverMinLandsOnTheLedger() await Utils.TestTransaction(client, setup.ToDictionary(), sender); AccountObjects created = await client.AccountObjects( - new AccountObjectsRequest(sender.ClassicAddress) { Type = LedgerEntryType.Check }); + new AccountObjectsRequest(sender.ClassicAddress) { Type = LedgerEntryType.Check }).Typed(); string checkId = created.AccountObjectList.Single().Index; // DeliverMin is the CheckCash branch the suite never exercised; rippled takes @@ -125,7 +125,7 @@ public async Task TestICheckCash_DeliverMinLandsOnTheLedger() await Utils.TestTransaction(client, cash.ToDictionary(), receiver); AccountObjects afterCash = await client.AccountObjects( - new AccountObjectsRequest(sender.ClassicAddress) { Type = LedgerEntryType.Check }); + new AccountObjectsRequest(sender.ClassicAddress) { Type = LedgerEntryType.Check }).Typed(); Assert.IsEmpty(afterCash.AccountObjectList, "the check must be consumed"); } finally @@ -161,7 +161,7 @@ public async Task TestINFTokenMint_OfferFieldsLandOnTheLedger() await Utils.TestTransaction(client, mint.ToDictionary(), minter); AccountObjects offers = await client.AccountObjects( - new AccountObjectsRequest(minter.ClassicAddress) { Type = LedgerEntryType.NFTokenOffer }); + new AccountObjectsRequest(minter.ClassicAddress) { Type = LedgerEntryType.NFTokenOffer }).Typed(); LONFTokenOffer offer = offers.AccountObjectList.OfType().Single(); Assert.AreEqual("5000000", offer.Amount.Value, "the mint-time sell offer must carry Amount"); @@ -204,7 +204,7 @@ public async Task TestIAccountSet_NewModelFieldsSurviveTheLedgerRoundTrip() await Utils.LedgerAccept(client); // Back out of the ledger and into the typed model - the full cycle the models used to break - TransactionResponse readBack = await client.Tx(new TxRequest(hash)); + TransactionResponse readBack = await client.TxV1(new TxRequest(hash)).Typed(); Assert.AreEqual(operationLimit, readBack.OperationLimit, "OperationLimit must survive the ledger round trip"); AccountSetResponse typed = readBack as AccountSetResponse; @@ -212,7 +212,7 @@ public async Task TestIAccountSet_NewModelFieldsSurviveTheLedgerRoundTrip() Assert.AreEqual(walletLocator, typed.WalletLocator); Assert.AreEqual(3u, typed.WalletSize); - AccountInfo info = await client.AccountInfo(new AccountInfoRequest(wallet.ClassicAddress)); + AccountInfo info = await client.AccountInfo(new AccountInfoRequest(wallet.ClassicAddress)).Typed(); Assert.AreEqual(walletLocator, info.AccountData.WalletLocator, "WalletLocator must be stored on the account root"); } finally diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs b/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs index fb4ec55e..46e83f98 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs @@ -56,7 +56,7 @@ private static async Task GetSponsorshipObject(string ownerAddres { Type = LedgerEntryType.Sponsorship, }; - AccountObjects response = await client.AccountObjects(request); + AccountObjects response = await client.AccountObjects(request).Typed(); return response?.AccountObjectList? .OfType() diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestITypedSubmitFailure.cs b/Tests/Xrpl.Tests/Integration/transactions/TestITypedSubmitFailure.cs new file mode 100644 index 00000000..9573c699 --- /dev/null +++ b/Tests/Xrpl.Tests/Integration/transactions/TestITypedSubmitFailure.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Client; +using Xrpl.Client.Exceptions; +using Xrpl.Sugar; +using Xrpl.Wallet; + +namespace XrplTests.Xrpl.ClientLib.Integration; + +/// +/// A failed submission arrives as something a caller can act on - issue #131. +/// +/// +/// Unit tests can build the exception and check its shape; only a node can show that the shape is +/// filled in on the path that actually produces it. This sends a payment a node refuses for a +/// reason that is easy to arrange and impossible to mistake for anything else. +/// +[TestClass] +public class TestITypedSubmitFailure +{ + private static IXrplClient client; + private static XrplWallet wallet; + + [ClassInitialize] + public static async Task ClassInitializeAsync(TestContext testContext) + { + client = await IntegrationTestConfig.CreateClientAsync(TestNodeType.Standalone); + wallet = await Utils.GenerateFundedWallet(client); + } + + [ClassCleanup] + public static void ClassCleanup() => client?.Dispose(); + + /// + /// A tec reaches a ledger, so the failure carries the code, the hash and the transaction. + /// + /// + /// One drop to an account that does not exist is below the reserve needed to create it, which + /// the node answers with tecNO_DST_INSUF_XRP: applied, fee taken, and there in the + /// ledger to be looked up. That is exactly the case where the caller has something to show and + /// used to have only a sentence to parse. + /// + /// Which moment reports it is a race with ledger closing, and the assertions below are chosen + /// to be true at both: see the note beside them. + /// + /// + [TestMethod] + public async Task TestIAFailureInALedgerArrivesTyped() + { + Dictionary tx = new Dictionary + { + { "TransactionType", "Payment" }, + { "Account", wallet.ClassicAddress }, + { "Destination", XrplWallet.Generate().ClassicAddress }, + { "Amount", "1" }, + }; + + TransactionFailedException error = await Assert.ThrowsExactlyAsync( + () => client.SubmitAndWait(tx, wallet)); + + Assert.AreEqual( + "tecNO_DST_INSUF_XRP", + error.EngineResult, + $"The code must arrive as a code, not as prose to search. Message was: {error.Message}"); + Assert.IsTrue( + error.ReachedLedger, + "A tec was applied to a ledger: the fee is gone, and that is the caller's business to know."); + Assert.IsFalse( + string.IsNullOrEmpty(error.Hash), + "Without the hash there is no way to show the transaction that just cost a fee."); + + // Result is deliberately not asserted 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 the metadata, or earlier from the node's provisional answer, when only + // the code and the hash exist. Requiring the summary here would make this test a race with + // ledger timing. What can be required is that it does not contradict the code when it is + // there - and that ReachedLedger says the same thing either way, which is why it is read + // from the code rather than from this. + if (error.Result is not null) + { + Assert.AreEqual( + "tecNO_DST_INSUF_XRP", + error.Result.Meta?.TransactionResult, + "The metadata that came with it must be the same outcome, not a second story."); + } + } + + /// + /// And it is still a RippleException, so code written before this keeps working. + /// + [TestMethod] + public async Task TestITheFailureIsStillARippleException() + { + Dictionary tx = new Dictionary + { + { "TransactionType", "Payment" }, + { "Account", wallet.ClassicAddress }, + { "Destination", XrplWallet.Generate().ClassicAddress }, + { "Amount", "1" }, + }; + + // Caught by the base type on purpose: that a derived exception still lands in an + // existing catch is the compatibility claim, and asserting the exact type would test + // something else. + RippleException error = null; + try + { + await client.SubmitAndWait(tx, wallet); + } + catch (RippleException thrown) + { + error = thrown; + } + + Assert.IsNotNull(error, "The payment must have been refused."); + + StringAssert.Contains( + error.Message, + "Final tx result is not success", + "The message is unchanged on purpose: consumers matching on it are not broken by this."); + } +} diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIVault.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIVault.cs index 62bb5b15..2ac33197 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIVault.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIVault.cs @@ -458,7 +458,7 @@ public async Task TestVaultLedgerEntry_VerifyFields() // Fetch the Vault LO via ledger_entry LedgerEntryRequest entryRequest = new LedgerEntryRequest { Index = vaultId }; - LedgerEntryResponse entryResponse = await client.LedgerEntry(entryRequest); + LedgerEntryResponse entryResponse = await client.LedgerEntry(entryRequest).Typed(); Assert.IsNotNull(entryResponse?.Node, "LedgerEntry node should not be null"); Assert.IsInstanceOfType(entryResponse.Node, typeof(LOVault), "Node should deserialize to LOVault"); @@ -615,7 +615,7 @@ public async Task TestVaultInfo_Basic() Assert.IsNotNull(vaultId, "VaultCreate should produce a Vault ledger object"); // Query vault_info - VaultInfoResponse vaultInfo = await client.VaultInfo(new VaultInfoRequest { VaultID = vaultId }); + VaultInfoResponse vaultInfo = await client.VaultInfo(new VaultInfoRequest { VaultID = vaultId }).Typed(); Assert.IsNotNull(vaultInfo, "vault_info should return a response"); Assert.IsNotNull(vaultInfo.Vault, "vault_info response should contain a Vault object"); Assert.AreEqual(wallet.ClassicAddress, vaultInfo.Vault.Owner, "Vault owner should match the creator"); diff --git a/Tests/Xrpl.Tests/Integration/transactions/accountDelete.cs b/Tests/Xrpl.Tests/Integration/transactions/accountDelete.cs index c5a86d4d..a24a1240 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/accountDelete.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/accountDelete.cs @@ -8,6 +8,7 @@ using Xrpl.Models.Transactions; using Xrpl.Wallet; +using Xrpl.Client; // https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/test/integration/transactions/accountDelete.ts namespace XrplTests.Xrpl.ClientLib.Integration @@ -38,7 +39,7 @@ public async Task TestRequestMethod() LedgerIndex index = new LedgerIndex(LedgerIndexType.Validated); AccountChannelsRequest request = new AccountChannelsRequest(runner.wallet.ClassicAddress) { LedgerIndex = index }; - AccountChannels response = await runner.client.AccountChannels(request); + AccountChannels response = await runner.client.AccountChannels(request).Typed(); Assert.IsNotNull(response); AccountDelete tx = new AccountDelete { diff --git a/Tests/Xrpl.Tests/Integration/transactions/checkCancel.cs b/Tests/Xrpl.Tests/Integration/transactions/checkCancel.cs index 828d9b4c..e2a82c0e 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/checkCancel.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/checkCancel.cs @@ -40,7 +40,7 @@ public async Task TestRequestMethod() await Utils.TestTransaction(client, setupJson, wallet1); AccountObjectsRequest request1 = new AccountObjectsRequest(wallet1.ClassicAddress) { Type = LedgerEntryType.Check }; - AccountObjects response1 = await client.AccountObjects(request1); + AccountObjects response1 = await client.AccountObjects(request1).Typed(); string checkId = response1.AccountObjectList[0].Index; CheckCancel tx = new CheckCancel @@ -52,7 +52,7 @@ public async Task TestRequestMethod() await Utils.TestTransaction(client, txJson, wallet1); AccountObjectsRequest request2 = new AccountObjectsRequest(wallet1.ClassicAddress) { Type = LedgerEntryType.Check }; - AccountObjects response2 = await client.AccountObjects(request2); + AccountObjects response2 = await client.AccountObjects(request2).Typed(); Assert.IsEmpty(response2.AccountObjectList); } finally diff --git a/Tests/Xrpl.Tests/Integration/transactions/checkCash.cs b/Tests/Xrpl.Tests/Integration/transactions/checkCash.cs index 7954b1d7..2d0dc80a 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/checkCash.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/checkCash.cs @@ -40,7 +40,7 @@ public async Task TestRequestMethod() await Utils.TestTransaction(client, setupJson, wallet1); AccountObjectsRequest request1 = new AccountObjectsRequest(wallet1.ClassicAddress) { Type = LedgerEntryType.Check }; - AccountObjects response1 = await client.AccountObjects(request1); + AccountObjects response1 = await client.AccountObjects(request1).Typed(); string checkId = response1.AccountObjectList[0].Index; CheckCash tx = new CheckCash @@ -53,7 +53,7 @@ public async Task TestRequestMethod() await Utils.TestTransaction(client, txJson, wallet2); AccountObjectsRequest request2 = new AccountObjectsRequest(wallet1.ClassicAddress) { Type = LedgerEntryType.Check }; - AccountObjects response2 = await client.AccountObjects(request2); + AccountObjects response2 = await client.AccountObjects(request2).Typed(); Assert.IsEmpty(response2.AccountObjectList); } finally diff --git a/Tests/Xrpl.Tests/Integration/transactions/checkCreate.cs b/Tests/Xrpl.Tests/Integration/transactions/checkCreate.cs index 87df9704..a2460f5a 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/checkCreate.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/checkCreate.cs @@ -7,6 +7,7 @@ using Xrpl.Models.Transactions; using Xrpl.Wallet; +using Xrpl.Client; // https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/test/integration/transactions/checkCreate.ts namespace XrplTests.Xrpl.ClientLib.Integration @@ -44,7 +45,7 @@ public async Task TestRequestMethod() // get check ID AccountObjectsRequest request1 = new AccountObjectsRequest(runner.wallet.ClassicAddress) { Type = LedgerEntryType.Check }; - AccountObjects response1 = await runner.client.AccountObjects(request1); + AccountObjects response1 = await runner.client.AccountObjects(request1).Typed(); Assert.HasCount(1, response1.AccountObjectList); } } diff --git a/Tests/Xrpl.Tests/Integration/transactions/offerCancel.cs b/Tests/Xrpl.Tests/Integration/transactions/offerCancel.cs index 05abafcc..f6ee6cf2 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/offerCancel.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/offerCancel.cs @@ -6,6 +6,7 @@ using Xrpl.Models.Transactions; using Xrpl.Wallet; +using Xrpl.Client; // https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/test/integration/transactions/offerCancel.ts namespace XrplTests.Xrpl.ClientLib.Integration @@ -43,7 +44,7 @@ public async Task TestRequestMethod() // sequence AccountOffersRequest request1 = new AccountOffersRequest(runner.wallet.ClassicAddress); - AccountOffers response1 = await runner.client.AccountOffers(request1); + AccountOffers response1 = await runner.client.AccountOffers(request1).Typed(); uint sequence = (uint)response1.Offers[0].Sequence; // actually test OfferCancel @@ -56,7 +57,7 @@ public async Task TestRequestMethod() await Utils.TestTransaction(runner.client, txJson, runner.wallet); AccountOffersRequest request2 = new AccountOffersRequest(runner.wallet.ClassicAddress); - AccountOffers response2 = await runner.client.AccountOffers(request1); + AccountOffers response2 = await runner.client.AccountOffers(request2).Typed(); Assert.IsEmpty(response2.Offers); } } diff --git a/Tests/Xrpl.Tests/Integration/transactions/offerCreate.cs b/Tests/Xrpl.Tests/Integration/transactions/offerCreate.cs index e57f2a01..5ccb59b2 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/offerCreate.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/offerCreate.cs @@ -6,6 +6,7 @@ using Xrpl.Models.Transactions; using Xrpl.Wallet; +using Xrpl.Client; // https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/test/integration/transactions/offerCreate.ts namespace XrplTests.Xrpl.ClientLib.Integration @@ -42,7 +43,7 @@ public async Task TestRequestMethod() await Utils.TestTransaction(runner.client, setupJson, runner.wallet); AccountOffersRequest request2 = new AccountOffersRequest(runner.wallet.ClassicAddress); - AccountOffers response2 = await runner.client.AccountOffers(request2); + AccountOffers response2 = await runner.client.AccountOffers(request2).Typed(); Assert.HasCount(1, response2.Offers); } } diff --git a/Tests/Xrpl.Tests/Models/TestDomainAccess.cs b/Tests/Xrpl.Tests/Models/TestDomainAccess.cs index 3b06689c..9a22c3eb 100644 --- a/Tests/Xrpl.Tests/Models/TestDomainAccess.cs +++ b/Tests/Xrpl.Tests/Models/TestDomainAccess.cs @@ -144,5 +144,30 @@ public void TestEvaluate_MultipleInvalid_AllReported() Assert.IsFalse(result.HasAccess); Assert.AreEqual(2, result.InvalidCredentials.Count); } + + [TestMethod] + public void TestEvaluate_MissingFlags_TreatedAsNotAccepted_NoAccess() + { + // Regression guard: Flags is nullable (e.g. a caller assembling a credential from + // NewFields/PreviousFields, where absence is normal). A missing Flags value must not + // be read as "accepted" - a lifted `!=` returns true when either side is null, unlike + // a lifted `<`, which returns false, so this is a distinct failure mode from the + // SignerQuorum case and must be checked explicitly rather than relying on the operator. + LOCredential credential = new LOCredential + { + Subject = "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + Issuer = Issuer, + CredentialType = CredentialTypeHex, + Expiration = null, + Flags = null + }; + List credentials = new List { credential }; + + DomainAccessResult result = DomainAccessSugar.EvaluateDomainAccess(credentials, CloseTime, LedgerIndex); + + Assert.IsFalse(result.HasAccess); + Assert.AreEqual(1, result.InvalidCredentials.Count); + Assert.IsFalse(result.InvalidCredentials[0].Accepted); + } } } diff --git a/Tests/Xrpl.Tests/Models/TestModelUtils.cs b/Tests/Xrpl.Tests/Models/TestModelUtils.cs index 0aeebd61..bf4c2dd5 100644 --- a/Tests/Xrpl.Tests/Models/TestModelUtils.cs +++ b/Tests/Xrpl.Tests/Models/TestModelUtils.cs @@ -33,11 +33,11 @@ public async Task TestVerifyValid_isFlagEnabled() //verifies a flag is enabled flags |= flag1 | flag2; - Assert.IsTrue(Index.IsFlagEnabled(flags, flag1)); + Assert.IsTrue(ModelUtils.IsFlagEnabled(flags, flag1)); //verifies a flag is not enabled flags = 0x00000000; flags |= flag2; - Assert.IsFalse(Index.IsFlagEnabled(flags, flag1)); + Assert.IsFalse(ModelUtils.IsFlagEnabled(flags, flag1)); } [TestMethod] public async Task TestVerifyValid_setTransactionFlagsToNumber() diff --git a/Tests/Xrpl.Tests/Models/TestUBaseTransactionResponseFields.cs b/Tests/Xrpl.Tests/Models/TestUBaseTransactionResponseFields.cs new file mode 100644 index 00000000..9d8e7711 --- /dev/null +++ b/Tests/Xrpl.Tests/Models/TestUBaseTransactionResponseFields.cs @@ -0,0 +1,268 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Text.Json; + +using Xrpl.Client.Json; +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; +using Xrpl.Models.Transactions; + +// Level 3, Task 1/2 of the raw-response initiative: close_time_iso, ctid, meta_blob and tx_blob +// had no property to land on at all. Every payload below is either a real mainnet capture, or a +// real capture reshaped to the API v1 wire form the same way TestUAccountTransactionsEnvelope +// already does (rippled genuinely flattens the transaction envelope for v1; this SDK's own +// PaymentJsonWithCtid fixture in TransactionResponseExtensionDataTests uses the same convention). +// Source captures: tx E08D6E9754025BA2534A78707605E0601F03ACE063687A0CA1BDDACFCD1698C7 (tx.json, +// tx binary.json), account rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh (account_tx.json, ledger.json). +namespace XrplTests.Xrpl.Models +{ + [TestClass] + public class TestUBaseTransactionResponseFields + { + private static readonly JsonSerializerOptions Options = XrplJsonOptions.Default; + + // account_tx entry for hash AB9D77240EE7414006F979CD8AF43BEAF9EC510F0E99DBFE7A2156BFB7DB56B6: + // API v2 nests ctid inside tx_json itself (unlike the singular tx method, where it sits + // beside tx_json — see TestUTransactionSummaryCtidFromRealTx below). + private const string PaymentTxJsonWithNestedCtid = """ + { + "Account": "rEPak6n2CEsQmowqsTMnkooskcLaGW9MzE", + "DeliverMax": "2", + "Destination": "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + "DestinationTag": 304200, + "Fee": "11", + "Flags": 0, + "LastLedgerSequence": 106227746, + "Sequence": 98882928, + "SigningPubKey": "ED4C767651D1B94D9F1EAA11119442F8228D473E0778926C49D34D3ACE3AFE08B5", + "TransactionType": "Payment", + "TxnSignature": "1823144FA737B57DB3418069B7D0ED7FFA57E3FA9ED4434F27EA26056B3D2AFA5E0E608D5C3744B250C1376CFA17F8FA22A8CF371442DBA756ABA8BF7D59080F", + "ctid": "C654E7BE00000000", + "date": 839790321, + "ledger_index": 106227646 + } + """; + + [TestMethod] + public void Deserialize_PaymentResponse_ReadsCtidNestedInTxJson() + { + PaymentResponse payment = JsonSerializer.Deserialize(PaymentTxJsonWithNestedCtid, Options); + + Assert.IsNotNull(payment); + Assert.AreEqual("C654E7BE00000000", payment.Ctid); + } + + [TestMethod] + public void Serialize_PaymentResponse_RoundTripsCtid() + { + PaymentResponse payment = JsonSerializer.Deserialize(PaymentTxJsonWithNestedCtid, Options); + + string output = JsonSerializer.Serialize(payment, Options); + + using JsonDocument doc = JsonDocument.Parse(output); + Assert.IsTrue(doc.RootElement.TryGetProperty("ctid", out JsonElement value)); + Assert.AreEqual("C654E7BE00000000", value.GetString()); + } + + // Reshaped to the v1 flat wire form: real close_time_iso/ctid/hash/ledger_index from the + // tx response envelope, merged onto the transaction object at one level the way rippled's + // API v1 genuinely does (Amount replaces DeliverMax, matching the v1/v2 alias already + // covered elsewhere in this SDK). + private const string PaymentV1FlatWithCloseTimeIsoAndCtid = """ + { + "TransactionType": "Payment", + "Account": "r3PDtZSa5LiYp1Ysn1vMuMzB59RzV3W9QH", + "Destination": "r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59", + "Amount": { "currency": "USD", "issuer": "r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59", "value": "1" }, + "Fee": "10", + "Sequence": 88, + "hash": "E08D6E9754025BA2534A78707605E0601F03ACE063687A0CA1BDDACFCD1698C7", + "ledger_index": 348734, + "close_time_iso": "2013-03-12T23:16:50Z", + "ctid": "C005523E00000000", + "validated": true + } + """; + + [TestMethod] + public void Deserialize_PaymentResponse_V1Flat_ReadsCloseTimeIsoAndCtid() + { + PaymentResponse payment = JsonSerializer.Deserialize(PaymentV1FlatWithCloseTimeIsoAndCtid, Options); + + Assert.IsNotNull(payment); + Assert.AreEqual(new DateTime(2013, 3, 12, 23, 16, 50, DateTimeKind.Utc), payment.CloseTimeIso); + Assert.AreEqual("C005523E00000000", payment.Ctid); + Assert.AreEqual("E08D6E9754025BA2534A78707605E0601F03ACE063687A0CA1BDDACFCD1698C7", payment.Hash); + } + + [TestMethod] + public void Serialize_PaymentResponse_V1Flat_RoundTripsCloseTimeIsoAndCtid() + { + PaymentResponse payment = JsonSerializer.Deserialize(PaymentV1FlatWithCloseTimeIsoAndCtid, Options); + + string output = JsonSerializer.Serialize(payment, Options); + + using JsonDocument doc = JsonDocument.Parse(output); + Assert.IsTrue(doc.RootElement.TryGetProperty("close_time_iso", out JsonElement closeTimeIso)); + Assert.AreEqual("2013-03-12T23:16:50Z", closeTimeIso.GetString()); + Assert.IsTrue(doc.RootElement.TryGetProperty("ctid", out JsonElement ctid)); + Assert.AreEqual("C005523E00000000", ctid.GetString()); + } + + // Real "result" of a tx (API v2) response — rippled puts close_time_iso and ctid beside + // tx_json here, not nested inside it (contrast with PaymentTxJsonWithNestedCtid above). + private const string TxV2Result = """ + { + "close_time_iso": "2013-03-12T23:16:50Z", + "ctid": "C005523E00000000", + "hash": "E08D6E9754025BA2534A78707605E0601F03ACE063687A0CA1BDDACFCD1698C7", + "ledger_hash": "195F62F34EB2CCFA4C5888BA20387E82EB353DDB4508BAE6A835AF19FB8B0C09", + "ledger_index": 348734, + "meta": { + "TransactionIndex": 0, + "TransactionResult": "tesSUCCESS", + "delivered_amount": "unavailable" + }, + "status": "success", + "tx_json": { + "Account": "r3PDtZSa5LiYp1Ysn1vMuMzB59RzV3W9QH", + "DeliverMax": { "currency": "USD", "issuer": "r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59", "value": "1" }, + "Destination": "r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59", + "Fee": "10", + "Sequence": 88, + "SigningPubKey": "02EAE5DAB54DD8E1C49641D848D5B97D1B29149106174322EDF98A1B2CCE5D7F8E", + "TransactionType": "Payment", + "TxnSignature": "30440220791B6A3E036ECEFFE99E8D4957564E8C84D1548C8C3E80A87ED1AA646ECCFB16022037C5CAC97E34E3021EBB426479F2ACF3ACA75DB91DCC48D1BCFB4CF547CFEAA0", + "date": 416445410, + "ledger_index": 348734 + }, + "validated": true + } + """; + + [TestMethod] + public void Deserialize_TransactionSummary_ReadsCtidBesideTxJson() + { + TransactionSummary summary = JsonSerializer.Deserialize(TxV2Result, Options); + + Assert.IsNotNull(summary); + Assert.AreEqual("C005523E00000000", summary.Ctid); + Assert.AreEqual(new DateTime(2013, 3, 12, 23, 16, 50, DateTimeKind.Utc), summary.CloseTimeIso); + } + + [TestMethod] + public void Serialize_TransactionSummary_RoundTripsCtid() + { + TransactionSummary summary = JsonSerializer.Deserialize(TxV2Result, Options); + + string output = JsonSerializer.Serialize(summary, Options); + + using JsonDocument doc = JsonDocument.Parse(output); + Assert.IsTrue(doc.RootElement.TryGetProperty("ctid", out JsonElement value)); + Assert.AreEqual("C005523E00000000", value.GetString()); + } + + // Real "result" of a tx (API v2, binary: true) response for the same transaction as + // TxV2Result above. rippled drops "meta"/"tx_json" entirely and sends meta_blob/tx_blob + // instead — before these properties existed, TransactionSummary lost the response body + // wholesale (measured: 2246 B in, 195 B out). + private const string TxV2BinaryResult = """ + { + "close_time_iso": "2013-03-12T23:16:50Z", + "ctid": "C005523E00000000", + "hash": "E08D6E9754025BA2534A78707605E0601F03ACE063687A0CA1BDDACFCD1698C7", + "ledger_hash": "195F62F34EB2CCFA4C5888BA20387E82EB353DDB4508BAE6A835AF19FB8B0C09", + "ledger_index": 348734, + "meta_blob": "201C00000000F8E5110061250005521C55C26AA6B4F7C3B9F55E17CD0D11F12032A1C7AD2757229FFD277C9447A8815E6E", + "status": "success", + "tx_blob": "1200002200000000240000005861D4838D7EA4C6800000000000000000000000000055534400000000005E7B112523F68D2F5E879DB4EAC51C6698A6930468400000000000000A", + "validated": true + } + """; + + [TestMethod] + public void Deserialize_TransactionSummary_Binary_ReadsMetaBlobAndTxBlob() + { + TransactionSummary summary = JsonSerializer.Deserialize(TxV2BinaryResult, Options); + + Assert.IsNotNull(summary); + Assert.IsNotNull(summary.MetaBlob, "meta_blob did not bind to TransactionSummary.MetaBlob"); + Assert.IsNotNull(summary.TxBlob, "tx_blob did not bind to TransactionSummary.TxBlob"); + Assert.IsTrue(summary.MetaBlob.StartsWith("201C00000000F8E5", StringComparison.Ordinal)); + Assert.IsTrue(summary.TxBlob.StartsWith("1200002200000000", StringComparison.Ordinal)); + Assert.IsNull(summary.Transaction, "tx_json is absent from a binary response"); + } + + [TestMethod] + public void Serialize_TransactionSummary_Binary_RoundTripsMetaBlobAndTxBlob() + { + TransactionSummary summary = JsonSerializer.Deserialize(TxV2BinaryResult, Options); + + string output = JsonSerializer.Serialize(summary, Options); + + using JsonDocument doc = JsonDocument.Parse(output); + Assert.IsTrue(doc.RootElement.TryGetProperty("meta_blob", out JsonElement metaBlob), "output is missing meta_blob"); + string metaBlobValue = metaBlob.GetString(); + Assert.IsNotNull(metaBlobValue, "meta_blob serialized as JSON null instead of the blob string"); + Assert.IsTrue(metaBlobValue.StartsWith("201C00000000F8E5", StringComparison.Ordinal)); + Assert.IsTrue(doc.RootElement.TryGetProperty("tx_blob", out JsonElement txBlob), "output is missing tx_blob"); + string txBlobValue = txBlob.GetString(); + Assert.IsNotNull(txBlobValue, "tx_blob serialized as JSON null instead of the blob string"); + Assert.IsTrue(txBlobValue.StartsWith("1200002200000000", StringComparison.Ordinal)); + } + + // Real "result" of a ledger response (ledger 106359162). ledger.close_time_iso was + // already modeled on LedgerEntity before this level, but was measured as lost anyway: the + // shared FromStringDateTimeConverter silently returned null for "Z"-suffixed timestamps + // (fixed alongside this level — see TestUFromStringDateTimeConverter). This regression + // test exists to keep that fix honest for the ledger command specifically. + private const string LedgerResult = """ + { + "ledger": { + "account_hash": "D65BC295E298614947C224734E2C66BD7BADA6D7C82F9FBE7A9EA43DC327C1CA", + "close_flags": 0, + "close_time": 840298580, + "close_time_human": "2026-Aug-17 16:16:20.000000000 UTC", + "close_time_iso": "2026-08-17T16:16:20Z", + "close_time_resolution": 10, + "closed": true, + "ledger_hash": "333BFF26AA0ADE0276162C6DFE82D3D94D0EC7054DFFE5BF2F7BAF47A1E02920", + "ledger_index": 106359162, + "parent_close_time": 840298572, + "parent_hash": "E266C538805F7751D16AB2C1810ED1182E8ED9023E411592FE5E97BE743A3115", + "total_coins": "99985627508883176", + "transaction_hash": "91C5E6AC94F08892E932B89C24B8ED83CBA1608508CCDC62B50AFF892AB86A99" + }, + "ledger_hash": "333BFF26AA0ADE0276162C6DFE82D3D94D0EC7054DFFE5BF2F7BAF47A1E02920", + "ledger_index": 106359162, + "status": "success", + "validated": true + } + """; + + [TestMethod] + public void Deserialize_LOLedger_ReadsNestedCloseTimeIso() + { + LOLedger ledger = JsonSerializer.Deserialize(LedgerResult, Options); + + Assert.IsNotNull(ledger); + LedgerEntity entity = ledger.LedgerEntity as LedgerEntity; + Assert.IsNotNull(entity, "a non-binary ledger response deserializes to LedgerEntity"); + Assert.AreEqual(new DateTime(2026, 8, 17, 16, 16, 20, DateTimeKind.Utc), entity.CloseTimeIso); + } + + [TestMethod] + public void Serialize_LOLedger_RoundTripsNestedCloseTimeIso() + { + LOLedger ledger = JsonSerializer.Deserialize(LedgerResult, Options); + + string output = JsonSerializer.Serialize(ledger, Options); + + using JsonDocument doc = JsonDocument.Parse(output); + JsonElement ledgerElement = doc.RootElement.GetProperty("ledger"); + Assert.IsTrue(ledgerElement.TryGetProperty("close_time_iso", out JsonElement value)); + Assert.AreEqual("2026-08-17T16:16:20Z", value.GetString()); + } + } +} diff --git a/Tests/Xrpl.Tests/Models/TestULedgerEntryFieldsConformance.cs b/Tests/Xrpl.Tests/Models/TestULedgerEntryFieldsConformance.cs index 74e675d0..99add0ed 100644 --- a/Tests/Xrpl.Tests/Models/TestULedgerEntryFieldsConformance.cs +++ b/Tests/Xrpl.Tests/Models/TestULedgerEntryFieldsConformance.cs @@ -87,7 +87,11 @@ public class TestULedgerEntryFieldsConformance /// The JSON name a property maps to: when /// present, the property name otherwise. Properties marked /// never reach the wire and are excluded — that is where the computed helpers live - /// (DataParsed, MPTokenMetadataRow, Metadata, …). + /// (DataParsed, MPTokenMetadataRow, Metadata, …). Properties marked + /// (BaseLedgerEntry.UnknownFields) are excluded + /// too: that property is not itself a field of any ledger object — it is the catch-all + /// System.Text.Json pours anything undeclared into — so it never belongs in either side of + /// this diff. /// private static Dictionary WireProperties(Type model) { @@ -98,6 +102,9 @@ private static Dictionary WireProperties(Type model) if (property.GetCustomAttribute() != null) continue; + if (property.GetCustomAttribute() != null) + continue; + string name = property.GetCustomAttribute()?.Name ?? property.Name; map[name] = property; } diff --git a/Tests/Xrpl.Tests/Models/TestUMemoRules.cs b/Tests/Xrpl.Tests/Models/TestUMemoRules.cs new file mode 100644 index 00000000..729cf957 --- /dev/null +++ b/Tests/Xrpl.Tests/Models/TestUMemoRules.cs @@ -0,0 +1,341 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Collections.Generic; + +using Xrpl.Client.Exceptions; +using Xrpl.Models.Transactions; +using Xrpl.Wallet; + +namespace Xrpl.Tests.Models +{ + /// + /// Memos a node refuses locally are refused before the transaction is signed - issue #119. + /// + /// + /// + /// isMemoOkay runs in rippled's passesLocalChecks: the transaction is not relayed, + /// reaches no ledger and costs no fee, and the answer names no field. The consumer has by then + /// built, autofilled and signed it. These tests go through rather + /// than a validator, because that is the point of the fix: Validation.Validate is called + /// nowhere in production, so a rule that lived only there would be a rule nobody runs. + /// + /// + /// The two rules the codec already enforces - a member other than MemoType, + /// MemoData or MemoFormat inside a Memo, and a value that is not hex - are + /// not retested here. TestUStrictNestedFields covers the first, and duplicating them + /// would create a second place to keep in step with the same truth. + /// + /// + [TestClass] + public class TestUMemoRules + { + private const string Seed = "snGHNrPbHrdUcszeuDEigMdC1Lyyd"; + + /// + /// The largest MemoData that fits in a memo carrying nothing else. + /// + /// + /// 1 byte of object start marker + 1 of field id + 2 of length prefix (a value over 192 + /// bytes takes two) + the data + 1 of object end marker = 1024 exactly. + /// + private const int LargestMemoDataInOneMemo = 1019; + + private static Dictionary Payment(XrplWallet wallet) => new Dictionary + { + { "TransactionType", "Payment" }, + { "Account", wallet.ClassicAddress }, + { "Destination", "rQ3fNyLjbvcDaPNS4EAJY8aT9zR3uGk17c" }, + { "Amount", "1000" }, + { "Fee", "12" }, + { "Sequence", 1u }, + { "LastLedgerSequence", 100u }, + }; + + private static Dictionary Memo(string memoData, string memoType = null, string memoFormat = null) + { + Dictionary memo = new Dictionary(); + if (memoType != null) + { + memo["MemoType"] = memoType; + } + + if (memoData != null) + { + memo["MemoData"] = memoData; + } + + if (memoFormat != null) + { + memo["MemoFormat"] = memoFormat; + } + + return new Dictionary { { "Memo", memo } }; + } + + private static string HexOf(int byteCount) => new string('A', byteCount * 2); + + /// + /// The boundary from below: a memo filling the limit exactly still signs. + /// + /// + /// Without this, a check that refused every memo would satisfy every other test here. + /// + [TestMethod] + public void TestUMemoAtTheLimitStillSigns() + { + XrplWallet wallet = XrplWallet.FromSeed(Seed); + Dictionary tx = Payment(wallet); + tx["Memos"] = new List { Memo(HexOf(LargestMemoDataInOneMemo)) }; + + SignatureResult signed = wallet.Sign(tx); + + Assert.IsFalse( + string.IsNullOrEmpty(signed.TxBlob), + "A memo of exactly the maximum size is legal and must still produce a blob."); + } + + /// + /// One byte over, and it is refused - before a signature exists. + /// + [TestMethod] + public void TestUMemoOverTheLimitIsRefusedBeforeSigning() + { + XrplWallet wallet = XrplWallet.FromSeed(Seed); + Dictionary tx = Payment(wallet); + tx["Memos"] = new List { Memo(HexOf(LargestMemoDataInOneMemo + 1)) }; + + ValidationException error = Assert.ThrowsExactly( + () => wallet.Sign(tx), + "one byte past the limit is what the node refuses, so signing it is work thrown away"); + + StringAssert.Contains( + error.Message, + "1025", + "The message must say how large the array actually came out, or the caller is left guessing how much to cut."); + Assert.IsFalse( + tx.ContainsKey("TxnSignature"), + "Refused before signing: nothing may have been added to the transaction."); + } + + /// + /// The limit is on the array, not on one memo, so splitting the content does not get round it. + /// + /// + /// Worth its own test because the opposite is the natural assumption, and acting on it + /// costs another round trip to a node that refuses the transaction just the same. + /// + [TestMethod] + public void TestUSeveralMemosShareOneLimit() + { + XrplWallet wallet = XrplWallet.FromSeed(Seed); + Dictionary tx = Payment(wallet); + tx["Memos"] = new List + { + Memo(HexOf(600)), + Memo(HexOf(600)), + }; + + ValidationException error = Assert.ThrowsExactly(() => wallet.Sign(tx)); + + StringAssert.Contains( + error.Message, + "whole array", + "The message must say the limit is on the array, since splitting is the obvious thing to try next."); + } + + /// + /// MemoType and MemoFormat may only decode to characters a URL allows. + /// + [TestMethod] + public void TestUMemoTypeMustDecodeToUrlSafeCharacters() + { + XrplWallet wallet = XrplWallet.FromSeed(Seed); + Dictionary tx = Payment(wallet); + + // "a b" - the space is legal hex and legal UTF-8, and not legal here. + tx["Memos"] = new List { Memo(memoData: "72656E74", memoType: "612062") }; + + ValidationException error = Assert.ThrowsExactly(() => wallet.Sign(tx)); + + StringAssert.Contains(error.Message, "MemoType"); + StringAssert.Contains( + error.Message, + "0x20", + "Naming the byte is what turns this from a puzzle into a fix."); + } + + /// + /// The same restriction on MemoFormat, which is the field it is easiest to forget. + /// + [TestMethod] + public void TestUMemoFormatMustDecodeToUrlSafeCharacters() + { + XrplWallet wallet = XrplWallet.FromSeed(Seed); + Dictionary tx = Payment(wallet); + tx["Memos"] = new List { Memo(memoData: "72656E74", memoFormat: "612062") }; + + ValidationException error = Assert.ThrowsExactly(() => wallet.Sign(tx)); + + StringAssert.Contains(error.Message, "MemoFormat"); + } + + /// + /// The restriction stops at MemoData: it carries arbitrary bytes by design. + /// + /// + /// The same bytes that are refused in a MemoType above must go through here, or the + /// check has been applied one field too widely - and a memo is mostly used for exactly the + /// content that is not URL-safe. + /// + [TestMethod] + public void TestUMemoDataMayHoldAnythingAtAll() + { + XrplWallet wallet = XrplWallet.FromSeed(Seed); + Dictionary tx = Payment(wallet); + tx["Memos"] = new List { Memo(memoData: "612062FF00", memoType: "687474703A2F2F612E62") }; + + SignatureResult signed = wallet.Sign(tx); + + Assert.IsFalse(string.IsNullOrEmpty(signed.TxBlob)); + } + + /// + /// A transaction without memos is not touched by any of this. + /// + [TestMethod] + public void TestUNoMemosSignsUnchanged() + { + XrplWallet wallet = XrplWallet.FromSeed(Seed); + + SignatureResult signed = wallet.Sign(Payment(wallet)); + + Assert.IsFalse(string.IsNullOrEmpty(signed.TxBlob)); + } + + /// + /// The rule holds for the helpers that sign without going through Sign. + /// + /// + /// Sign is not the only public way into a signature: SignAsSponsor, + /// SignAsLoanCounterparty and SignAsBatchPart each sign on their own, and the + /// SDK's own multi-batch submission calls the last of them directly (Submit.cs). A + /// guard on Sign alone would therefore have left the batch path unchecked - which is + /// how this was found, in review. + /// + [TestMethod] + public void TestUSigningAsSponsorIsCheckedToo() + { + XrplWallet wallet = XrplWallet.FromSeed(Seed); + Dictionary tx = Payment(wallet); + tx["Sponsor"] = wallet.ClassicAddress; + tx["Memos"] = new List { Memo(HexOf(LargestMemoDataInOneMemo + 1)) }; + + ValidationException error = Assert.ThrowsExactly( + () => wallet.SignAsSponsor(tx)); + + StringAssert.Contains(error.Message, "1025 bytes"); + } + + /// + /// The same for the borrower's co-signature on a LoanSet. + /// + [TestMethod] + public void TestUSigningAsLoanCounterpartyIsCheckedToo() + { + XrplWallet wallet = XrplWallet.FromSeed(Seed); + Dictionary tx = Payment(wallet); + tx["TransactionType"] = "LoanSet"; + tx["Counterparty"] = wallet.ClassicAddress; + tx["Memos"] = new List { Memo(HexOf(LargestMemoDataInOneMemo + 1)) }; + + ValidationException error = Assert.ThrowsExactly( + () => wallet.SignAsLoanCounterparty(tx)); + + StringAssert.Contains(error.Message, "1025 bytes"); + } + + /// + /// And for a batch part - the one the SDK itself signs directly. + /// + [TestMethod] + public void TestUSigningABatchPartIsCheckedToo() + { + XrplWallet wallet = XrplWallet.FromSeed(Seed); + Dictionary tx = Payment(wallet); + tx["TransactionType"] = "Batch"; + tx["Memos"] = new List { Memo(HexOf(LargestMemoDataInOneMemo + 1)) }; + + ValidationException error = Assert.ThrowsExactly( + () => wallet.SignAsBatchPart(tx, multisign: false, signingFor: wallet.ClassicAddress)); + + StringAssert.Contains(error.Message, "1025 bytes"); + } + + /// + /// A MemoType that is not a string at all is the codec's to refuse, not these rules'. + /// + /// + /// Reading it as a string would throw InvalidOperationException from deep inside + /// System.Text.Json - neither the exception a caller of the SDK expects nor a message + /// that names the field. The same principle as the rules the codec already owns: what these + /// rules cannot read, they leave alone. + /// + [TestMethod] + public void TestUANonStringMemoTypeIsLeftToTheCodec() + { + XrplWallet wallet = XrplWallet.FromSeed(Seed); + Dictionary tx = Payment(wallet); + tx["Memos"] = new List + { + new Dictionary + { + { + "Memo", new Dictionary + { + { "MemoType", 42 }, + { "MemoData", "72656E74" }, + } + }, + }, + }; + + Exception error = null; + try + { + wallet.Sign(tx); + } + catch (Exception thrown) + { + error = thrown; + } + + Assert.IsNotNull(error, "A MemoType that is not a string cannot end up on the wire."); + Assert.IsNotInstanceOfType( + error, + $"A non-string MemoType must be reported by whoever owns that rule, not leaked as a " + + $"JSON reader's own exception. Got: {error}"); + } + + /// + /// The measurement is the node's own: the array's serialized length, without the array's + /// own markers. + /// + /// + /// Asserted directly rather than only through signing, because the arithmetic is the part + /// that is easy to get wrong by a byte or two and hard to see afterwards. + /// + [TestMethod] + public void TestUTheLimitIsMeasuredTheWayANodeMeasuresIt() + { + List atTheLimit = new List { Memo(HexOf(LargestMemoDataInOneMemo)) }; + List justOver = new List { Memo(HexOf(LargestMemoDataInOneMemo + 1)) }; + + MemoRules.Validate(atTheLimit); + + ValidationException error = Assert.ThrowsExactly(() => MemoRules.Validate(justOver)); + + StringAssert.Contains(error.Message, $"{MemoRules.MaxSerializedLength + 1} bytes"); + } + } +} diff --git a/Tests/Xrpl.Tests/Models/TestUModelTruth.cs b/Tests/Xrpl.Tests/Models/TestUModelTruth.cs new file mode 100644 index 00000000..988d14a1 --- /dev/null +++ b/Tests/Xrpl.Tests/Models/TestUModelTruth.cs @@ -0,0 +1,229 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json; + +using Xrpl.Client.Exceptions; +using Xrpl.Client.Json; +using Xrpl.Models.Common; +using Xrpl.Models.Transactions; + +namespace Xrpl.Tests.Models +{ + /// + /// Places where the model told the consumer something that was not so. + /// + /// + /// Four separate reports, one theme: a type or a validator that answers confidently and wrongly. + /// None of them fails loudly - an inverted predicate returns a bool, a field that does not exist + /// in the protocol serializes fine, a pattern match on the wrong half of a type pair compiles + /// and finds nothing. The cost lands on the consumer, at a node refusal or, worse, at a + /// transaction that succeeded meaning something else. + /// + [TestClass] + public class TestUModelTruth + { + /// + /// IsMPTToken answered the exact opposite: issue #128. + /// + /// + /// The negation was missing, so every amount that is not a multi-purpose token was reported + /// as one. Nothing inside the SDK calls this method, which is why nothing showed it. + /// + [TestMethod] + public void TestUIsMPTTokenIsTrueForAnMPTAndFalseForEverythingElse() + { + Currency mpt = new Currency + { + MPTokenIssuanceID = "00000539C0B4D5EB1B4A8B0A5C2E0C8C6A0F6D1E2B3C4D5E", + Value = "10", + }; + Currency xrp = new Currency { ValueAsXrp = 5 }; + Currency issued = new Currency + { + CurrencyCode = "USD", + Issuer = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + Value = "10", + }; + + Assert.IsTrue(mpt.IsMPTToken(), "An amount carrying an issuance id is the only kind this can be true of."); + Assert.IsFalse(xrp.IsMPTToken(), "XRP is not a multi-purpose token."); + Assert.IsFalse(issued.IsMPTToken(), "An issued currency is not a multi-purpose token."); + Assert.IsFalse(((Currency)null).IsMPTToken(), "Nothing at all is not a multi-purpose token either."); + } + + /// + /// NFTokenAcceptOffer no longer offers a field the protocol does not have: issue #129. + /// + /// + /// rippled's transactions.macro gives this transaction exactly three of its own + /// fields - NFTokenBuyOffer, NFTokenSellOffer, NFTokenBrokerFee. The + /// model declared a fourth, NFTokenID, and it serialized like any other: the type + /// suggested it, IntelliSense offered it, and whoever filled it in got a node refusal with + /// no hint from the types at all. + /// + /// Asserted through the property list rather than only through serialization, because the + /// serialized form hides an unset property and the point is that the property is gone. + /// + /// + [TestMethod] + public void TestUNFTokenAcceptOfferHasNoNFTokenID() + { + foreach (Type type in new[] + { + typeof(NFTokenAcceptOffer), + typeof(NFTokenAcceptOfferResponse), + typeof(INFTokenAcceptOffer), + }) + { + Assert.IsNull( + type.GetProperty("NFTokenID"), + $"{type.Name} still declares NFTokenID, which NFTokenAcceptOffer does not have in the protocol."); + } + } + + /// + /// All three fields it does have still serialize, so the removal took nothing real with it. + /// + /// + /// This does not also assert that NFTokenID is absent from the JSON, which would look + /// like extra safety and be none: null properties are omitted anyway, so that assertion + /// would have passed just as well before the property was removed. The absence is the + /// reflection test's to prove. + /// + [TestMethod] + public void TestUNFTokenAcceptOfferStillCarriesItsOwnThreeFields() + { + NFTokenAcceptOffer accept = new NFTokenAcceptOffer + { + Account = "r4f4xLpXJtCh9PwdzsQ6KYwLevVnBpJV6f", + NFTokenSellOffer = "392578EC763875C71944D25F07528F28D5460A6DD2958A17792380D9E2B430A7", + NFTokenBuyOffer = "68CD1F6F906494EA08C9CB5CAFA64DFA90D4E834B7151899B73231DE5A0C3B77", + NFTokenBrokerFee = new Currency { ValueAsXrp = 1 }, + }; + + string json = JsonSerializer.Serialize(accept, XrplJsonOptions.Default); + + StringAssert.Contains(json, "NFTokenSellOffer"); + StringAssert.Contains(json, "NFTokenBuyOffer"); + StringAssert.Contains(json, "NFTokenBrokerFee"); + } + + /// + /// The same offer on both sides is a refusal the node charges for, and one comparison + /// catches it: issue #134. + /// + /// + /// rippled compares each offer's owner with the submitter separately - the two blocks in + /// preclaim are not alternatives - so naming one offer twice makes one of those + /// comparisons the account against itself, and the answer is + /// tecCANT_ACCEPT_OWN_NFTOKEN_OFFER: in a ledger, fee taken. + /// + [TestMethod] + public async System.Threading.Tasks.Task TestUTheSameOfferOnBothSidesIsRefused() + { + const string offer = "392578EC763875C71944D25F07528F28D5460A6DD2958A17792380D9E2B430A7"; + Dictionary tx = new Dictionary + { + { "TransactionType", "NFTokenAcceptOffer" }, + { "Account", "r4f4xLpXJtCh9PwdzsQ6KYwLevVnBpJV6f" }, + { "NFTokenSellOffer", offer }, + { "NFTokenBuyOffer", offer }, + }; + + ValidationException error = await Assert.ThrowsExactlyAsync( + () => Validation.ValidateNFTokenAcceptOffer(tx)); + + StringAssert.Contains(error.Message, "different offers"); + } + + /// + /// Two different offers are brokered mode, which is legal and must stay so. + /// + /// + /// Without this, a check that refused every brokered transaction would satisfy the test above. + /// + [TestMethod] + public async System.Threading.Tasks.Task TestUTwoDifferentOffersAreAccepted() + { + Dictionary tx = new Dictionary + { + { "TransactionType", "NFTokenAcceptOffer" }, + { "Account", "r4f4xLpXJtCh9PwdzsQ6KYwLevVnBpJV6f" }, + { "NFTokenSellOffer", "392578EC763875C71944D25F07528F28D5460A6DD2958A17792380D9E2B430A7" }, + { "NFTokenBuyOffer", "68CD1F6F906494EA08C9CB5CAFA64DFA90D4E834B7151899B73231DE5A0C3B77" }, + }; + + await Validation.ValidateNFTokenAcceptOffer(tx); + } + + /// + /// Transactions read back from the ledger can be matched on the I interface they + /// share with their request type: issue #135. + /// + /// + /// + /// The advice this test guards is in the README and on + /// TransactionSummary.Transaction: match on INFTokenCreateOffer, never on + /// NFTokenCreateOffer, because what arrives is the response half. Advice like that + /// is only worth giving while it holds for the types it is given about. + /// + /// + /// Five pairs do not hold it, and they are listed rather than hidden: the + /// ConfidentialMPT set carries no interface at all - neither half declares one - so + /// for those there is nothing to match on. The list is the point of the test as much as the + /// invariant is: a sixth such type added tomorrow fails here rather than being discovered + /// by a consumer whose pattern match silently found nothing. + /// + /// + [TestMethod] + public void TestURequestAndResponseHalvesShareAnInterface() + { + HashSet knownWithoutAnInterface = new HashSet(StringComparer.Ordinal) + { + "ConfidentialMPTConvert", + "ConfidentialMPTConvertBack", + "ConfidentialMPTMergeInbox", + "ConfidentialMPTSend", + "ConfidentialMPTClawback", + }; + + Assembly assembly = typeof(TransactionRequest).Assembly; + List missing = new List(); + int pairs = 0; + + foreach (Type response in assembly.GetTypes() + .Where(type => type.IsClass && !type.IsAbstract) + .Where(type => type.Name.EndsWith("Response", StringComparison.Ordinal)) + .Where(typeof(TransactionResponse).IsAssignableFrom)) + { + string requestName = response.Name.Substring(0, response.Name.Length - "Response".Length); + Type request = assembly.GetType($"{response.Namespace}.{requestName}"); + if (request is null || knownWithoutAnInterface.Contains(requestName)) + { + continue; + } + + pairs++; + bool shared = request.GetInterfaces() + .Intersect(response.GetInterfaces()) + .Any(contract => contract.Name.StartsWith("I" + requestName, StringComparison.Ordinal)); + + if (!shared) + { + missing.Add(requestName); + } + } + + Assert.IsTrue(pairs > 50, $"The scan must actually have found the model types; it saw {pairs} pairs."); + Assert.AreEqual( + 0, + missing.Count, + $"These request/response pairs share no I-interface, so history cannot be matched on " + + $"one and the advice in the README does not hold for them: {string.Join(", ", missing)}"); + } + } +} diff --git a/Tests/Xrpl.Tests/Models/TestUModelledResponseFields.cs b/Tests/Xrpl.Tests/Models/TestUModelledResponseFields.cs new file mode 100644 index 00000000..61e776a5 --- /dev/null +++ b/Tests/Xrpl.Tests/Models/TestUModelledResponseFields.cs @@ -0,0 +1,217 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +using Xrpl.Client.Json; +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; + +namespace XrplTests.Models; + +/// +/// Fields the node sends and no model declared: they now land on typed properties instead of in +/// UnknownFields. +/// +/// +/// Unknown-field capture made the loss visible rather than silent - before it, these vanished +/// between the socket and the caller. Capture is the safety net; declaring them is the fix, and a +/// field counts as done only when it is both a declared property and gone from +/// UnknownFields. Each test here asserts both halves, because either alone can pass while +/// the other fails: a property can be declared under a name the node never sends, and a field can +/// leave the capture by being dropped rather than by being modelled. +/// +/// The JSON is what a node actually answered, not what the documentation says it answers. The +/// types came from the same place: server_state_duration_us is a string in +/// server_info while the same field is a number in server_state, and only asking a +/// node tells you that. +/// +/// +[TestClass] +public class TestUModelledResponseFields +{ + /// + /// An answer from rippled 3.3.0, trimmed of nothing that matters. + /// + private const string ServerInfoResult = """ + { + "build_version": "3.3.0", + "complete_ledgers": "2-14", + "git": { "branch": "release-3.3", "hash": "00a178fb92ca49521b937ae1a99d863765ea8a90" }, + "hostid": "34d90e50bf06", + "initial_sync_duration_us": "90874", + "io_latency_ms": 1, + "jq_trans_overflow": "0", + "last_close": { "converge_time_s": 0.1, "proposers": 0 }, + "load": { "job_types": [ { "avg_time": 1, "in_progress": 1, "job_type": "clientRPC", "peak_time": 6 } ], "threads": 1 }, + "load_factor": 1, + "network_id": 0, + "node_size": "small", + "peer_disconnects": "0", + "peer_disconnects_resources": "0", + "peers": 0, + "ports": [ + { "port": "5005", "protocol": [ "http" ] }, + { "port": "6006", "protocol": [ "ws" ] } + ], + "pubkey_node": "n9LQdAUHHBnkKFpSG296p2V1xWQB8zJEeWuZx4SUVXWxQMNbZ92e", + "pubkey_validator": "n9LEa2A2wpov7XaXC8XXiJwknbXUt29M27CWLWz4XzFSXd45eah8", + "server_state": "proposing", + "server_state_duration_us": "25380868", + "state_accounting": { "connected": { "duration_us": "0", "transitions": "0" } }, + "time": "2026-Aug-23 20:02:52.668044 UTC", + "uptime": 25, + "validated_ledger": { "age": 1, "base_fee_xrp": 1e-05, "seq": 14 }, + "validation_quorum": 1, + "validator_list": { "count": 0, "expiration": "unknown", "status": "unknown" } + } + """; + + private static IEnumerable Captured(Dictionary unknown) => + unknown is null ? Enumerable.Empty() : unknown.Keys.OrderBy(k => k); + + /// + /// Ten fields on server_info's info that no model declared. + /// + /// + /// The report named seven. Measuring against a node found three more - git, + /// node_size and validator_list - which is why this asserts on the whole capture + /// being empty rather than on a list of names: a list would have been the list from the report, + /// and would have missed exactly the ones nobody thought of. + /// + [TestMethod] + public void TestUServerInfoDeclaresEverythingTheNodeSends() + { + Info info = JsonSerializer.Deserialize(ServerInfoResult, XrplJsonOptions.Default); + + Assert.IsNotNull(info); + CollectionAssert.AreEqual( + new List(), + Captured(info.UnknownFields).ToList(), + "server_info sent members that no property claims"); + + Assert.AreEqual("90874", info.InitialSyncDurationUs); + Assert.AreEqual("0", info.JqTransOverflow); + Assert.AreEqual("0", info.PeerDisconnects); + Assert.AreEqual("0", info.PeerDisconnectsResources); + Assert.AreEqual("25380868", info.ServerStateDurationUs); + Assert.AreEqual("2026-Aug-23 20:02:52.668044 UTC", info.Time); + Assert.AreEqual("small", info.NodeSize); + + Assert.AreEqual("release-3.3", info.Git?.Branch); + Assert.AreEqual("00a178fb92ca49521b937ae1a99d863765ea8a90", info.Git?.Hash); + Assert.AreEqual("unknown", info.ValidatorList?.Status); + Assert.AreEqual("unknown", info.ValidatorList?.Expiration); + Assert.AreEqual(0, info.ValidatorList?.Count); + + Assert.AreEqual(2, info.Ports?.Count); + Assert.AreEqual("5005", info.Ports?[0].Port); + CollectionAssert.AreEqual(new List { "http" }, info.Ports?[0].Protocol); + CollectionAssert.AreEqual(new List { "ws" }, info.Ports?[1].Protocol); + + // The three new types capture too, and an assertion that only reads the top level would + // call the job done while `hash` or `expiration` sat untyped one level in - the same shape + // of miss this whole change is about. + CollectionAssert.AreEqual(new List(), Captured(info.Git?.UnknownFields).ToList(), + "git carried a member no property claims"); + CollectionAssert.AreEqual(new List(), Captured(info.ValidatorList?.UnknownFields).ToList(), + "validator_list carried a member no property claims"); + foreach (ServerPort port in info.Ports) + { + CollectionAssert.AreEqual(new List(), Captured(port.UnknownFields).ToList(), + $"the entry for port {port.Port} carried a member no property claims"); + } + } + + /// + /// account_lines was the one sibling that never declared validated. + /// + /// + /// rippled writes it through lookupLedger unconditionally, so it arrives on every + /// answer - a caller simply had no typed way to tell a validated answer from a provisional one. + /// + [TestMethod] + public void TestUAccountLinesDeclaresValidated() + { + const string json = """ + { + "account": "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + "ledger_hash": "1D5B45B3FD6E1895D8FB455DBD0BAB0D726B7F1FBB33E0DA24E9CDFC1AB4B26D", + "ledger_index": 14, + "lines": [], + "validated": true + } + """; + + AccountLines lines = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + + Assert.IsNotNull(lines); + Assert.AreEqual(true, lines.Validated, "validated arrives on every account_lines answer"); + CollectionAssert.DoesNotContain( + Captured(lines.UnknownFields).ToList(), + "validated", + "a declared property that still shows up in the capture is not declared under the name the node uses"); + } + + /// + /// A ledger call naming no ledger gets back two whole structures, neither of which is + /// the one LedgerEntity holds. + /// + /// + /// The trap in this one is the word: BaseLedgerEntity.Closed already existed, but that + /// is the boolean inside a ledger saying whether that ledger is closed - not the + /// top-level structure of the same name. + /// + [TestMethod] + public void TestULedgerDeclaresClosedAndOpen() + { + const string json = """ + { + "closed": { "ledger": { "account_hash": "9CC6B8ACCE49F5EF3213E4E051255389F3B08077BA6B4D2C4A6C6C2C6D3A1E5F", "closed": true, "ledger_index": "14" } }, + "open": { "ledger": { "closed": false, "ledger_index": "15", "parent_hash": "7953B1E3AC1B6A94A2A0C0A0C1B4D5E6F70819202A3B4C5D6E7F8091A2B3C4D5" } } + } + """; + + LOLedger ledger = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + + Assert.IsNotNull(ledger); + Assert.IsNotNull(ledger.ClosedLedger, "a ledger call naming nothing answers with a closed ledger"); + Assert.IsNotNull(ledger.OpenLedger, "and with the open one"); + + Assert.AreEqual(true, (ledger.ClosedLedger.LedgerEntity as BaseLedgerEntity)?.Closed); + Assert.AreEqual(false, (ledger.OpenLedger.LedgerEntity as BaseLedgerEntity)?.Closed); + + List captured = Captured(ledger.UnknownFields).ToList(); + CollectionAssert.DoesNotContain(captured, "closed"); + CollectionAssert.DoesNotContain(captured, "open"); + } + + /// + /// Escrow.Flags arrives even though no lsfEscrow* flag is defined. + /// + /// + /// The old comment reasoned that a field which is always zero need not be modelled. That + /// confuses "always zero" with "never sent": it shows up on every deleted Escrow node in + /// transaction metadata, and undeclared it went to the capture untyped. + /// + [TestMethod] + public void TestUEscrowDeclaresFlags() + { + const string json = """ + { + "Account": "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + "Destination": "rQ3fNyLjbvcDaPNS4EAJY8aT9zR3uGk17c", + "Amount": "10000", + "Flags": 0, + "Sequence": 3 + } + """; + + LOEscrow escrow = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + + Assert.IsNotNull(escrow); + Assert.AreEqual(0u, escrow.Flags, "the field arrives; being zero is not being absent"); + CollectionAssert.DoesNotContain(Captured(escrow.UnknownFields).ToList(), "Flags"); + } +} diff --git a/Tests/Xrpl.Tests/Models/TestUNodeMayOmitProtocolFields.cs b/Tests/Xrpl.Tests/Models/TestUNodeMayOmitProtocolFields.cs new file mode 100644 index 00000000..400e8c8e --- /dev/null +++ b/Tests/Xrpl.Tests/Models/TestUNodeMayOmitProtocolFields.cs @@ -0,0 +1,343 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System.Text.Json; + +using Xrpl.Client.Json; +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; +using Xrpl.Models.Subscriptions; + +// rippled-issue-6629 nullability pass: 9 response-model properties that were declared +// non-nullable even though the node genuinely omits them under conditions confirmed against the +// rippled C++ source (not xrpl.js - see the analysis this branch is built from). Each fixture +// below is the JSON shape rippled actually sends for the omission case; the assertions prove two +// things per field: (1) deserializing that shape leaves the property null rather than fabricating +// a default 0/false, and (2) re-serializing the result omits the member instead of writing back a +// value the node never sent - the fabrication this whole branch exists to remove. +namespace XrplTests.Xrpl.Models +{ + [TestClass] + public class TestUNodeMayOmitProtocolFields + { + private static readonly JsonSerializerOptions Options = XrplJsonOptions.Default; + + // rippled RPCLedgerHelpers.cpp lookupLedger: `if (!ledger->open()) { ledger_hash; + // ledger_index; } else { ledger_current_index; }` - an open/current ledger response omits + // the top-level ledger_index entirely and sends ledger_current_index instead. + // LOBaseLedger.LedgerIndex is inherited by LOLedger (the `ledger` command's response + // model), which reaches this branch whenever a caller asks for the current ledger. + // + // The same fixture also covers BaseLedgerEntity.Closed: rippled's LedgerToJson.cpp + // fillJson only omits the nested "closed" member for a non-binary response when the + // ledger is open AND full:true was requested - exactly the shape below (no "closed" key + // under "ledger"). + private const string OpenLedgerFullReply = """ + { + "ledger": { + "account_hash": "D65BC295E298614947C224734E2C66BD7BADA6D7C82F9FBE7A9EA43DC327C1CA", + "ledger_hash": "333BFF26AA0ADE0276162C6DFE82D3D94D0EC7054DFFE5BF2F7BAF47A1E02920", + "ledger_index": "106359163", + "parent_hash": "E266C538805F7751D16AB2C1810ED1182E8ED9023E411592FE5E97BE743A3115", + "transaction_hash": "91C5E6AC94F08892E932B89C24B8ED83CBA1608508CCDC62B50AFF892AB86A99" + }, + "ledger_current_index": 106359163, + "validated": false + } + """; + + [TestMethod] + public void Deserialize_LOLedger_OpenLedger_LedgerIndexAndClosedAreNull() + { + LOLedger ledger = JsonSerializer.Deserialize(OpenLedgerFullReply, Options); + + Assert.IsNotNull(ledger); + Assert.IsNull(ledger.LedgerIndex, "the node did not send a top-level ledger_index for an open ledger - the property must stay null, not fabricate 0"); + LedgerEntity entity = ledger.LedgerEntity as LedgerEntity; + Assert.IsNotNull(entity); + Assert.IsNull(entity.Closed, "the node omitted \"closed\" for this open+full response - the property must stay null, not fabricate false"); + } + + [TestMethod] + public void Serialize_LOLedger_OpenLedger_OmitsLedgerIndexAndClosed() + { + LOLedger ledger = JsonSerializer.Deserialize(OpenLedgerFullReply, Options); + + string output = JsonSerializer.Serialize(ledger, Options); + using JsonDocument doc = JsonDocument.Parse(output); + + Assert.IsFalse(doc.RootElement.TryGetProperty("ledger_index", out _), "re-serialized output must not fabricate a top-level ledger_index the node never sent"); + JsonElement nested = doc.RootElement.GetProperty("ledger"); + Assert.IsFalse(nested.TryGetProperty("closed", out _), "re-serialized output must not fabricate \"closed\" the node never sent"); + } + + // rippled LedgerToJson.cpp: the per-transaction "validated" member is only added inside + // the apiVersion > 1 branch of the ledger command's transaction expansion; the legacy v1 + // branch copies the flat transaction JSON and never writes it at all. + private const string LedgerTransactionApiV1Flat = """ + { + "hash": "AB9D77240EE7414006F979CD8AF43BEAF9EC510F0E99DBFE7A2156BFB7DB56B6", + "ledger_hash": "333BFF26AA0ADE0276162C6DFE82D3D94D0EC7054DFFE5BF2F7BAF47A1E02920", + "ledger_index": 106359162 + } + """; + + [TestMethod] + public void Deserialize_LedgerTransaction_ApiV1_ValidatedIsNull() + { + LedgerTransaction tx = JsonSerializer.Deserialize(LedgerTransactionApiV1Flat, Options); + + Assert.IsNotNull(tx); + Assert.IsNull(tx.Validated, "API v1 ledger transaction entries carry no \"validated\" member at all - the property must stay null, not fabricate false"); + } + + [TestMethod] + public void Serialize_LedgerTransaction_ApiV1_OmitsValidated() + { + LedgerTransaction tx = JsonSerializer.Deserialize(LedgerTransactionApiV1Flat, Options); + + string output = JsonSerializer.Serialize(tx, Options); + using JsonDocument doc = JsonDocument.Parse(output); + + Assert.IsFalse(doc.RootElement.TryGetProperty("validated", out _), "re-serialized output must not fabricate \"validated\" the node never sent"); + } + + // rippled's shared lookupLedger helper sets ledger_current_index only in the else branch + // of `if (!ledger->open())` - a noripple_check resolved against a closed/validated ledger + // sends ledger_hash/ledger_index instead (fields NoRippleCheck does not even model) and + // omits ledger_current_index entirely. + private const string NoRippleCheckValidatedLedger = """ + { + "ledger_hash": "333BFF26AA0ADE0276162C6DFE82D3D94D0EC7054DFFE5BF2F7BAF47A1E02920", + "ledger_index": 106359162, + "problems": [], + "validated": true + } + """; + + [TestMethod] + public void Deserialize_NoRippleCheck_ValidatedLedger_LedgerCurrentIndexIsNull() + { + NoRippleCheck result = JsonSerializer.Deserialize(NoRippleCheckValidatedLedger, Options); + + Assert.IsNotNull(result); + Assert.IsNull(result.LedgerCurrentIndex, "a noripple_check resolved against a closed ledger never sends ledger_current_index - the property must stay null, not fabricate 0"); + } + + [TestMethod] + public void Serialize_NoRippleCheck_ValidatedLedger_OmitsLedgerCurrentIndex() + { + NoRippleCheck result = JsonSerializer.Deserialize(NoRippleCheckValidatedLedger, Options); + + string output = JsonSerializer.Serialize(result, Options); + using JsonDocument doc = JsonDocument.Parse(output); + + Assert.IsFalse(doc.RootElement.TryGetProperty("ledger_current_index", out _), "re-serialized output must not fabricate ledger_current_index the node never sent"); + } + + // rippled's `feature` handler (Feature.cpp) never calls lookupLedger and never writes + // ledger_hash/ledger_index/validated at all - every real `feature` response omits + // ledger_index unconditionally, not just sometimes. + private const string FeatureResponseWithoutLedgerFields = """ + { + "7DB0788C020F02780A673DC74757F23823FA3014C1866E72CC4CD8B226CD6EF4": { + "enabled": true, + "name": "MultiSign", + "supported": true + } + } + """; + + [TestMethod] + public void Deserialize_ServerFeatures_LedgerIndexIsNull() + { + // ServerFeaturesConverter.Write throws NotSupportedException (serialization of this + // type is not required), so unlike the other fields in this file there is no + // re-serialization half to assert on here - only that the converter stopped defaulting + // the missing member to 0. + ServerFeatures result = JsonSerializer.Deserialize(FeatureResponseWithoutLedgerFields, Options); + + Assert.IsNotNull(result); + Assert.IsNull(result.LedgerIndex, "the feature command never sends ledger_index - the property must stay null, not fabricate 0"); + + // ServerFeaturesConverter reads Validated with the same ternary shape as LedgerIndex + // (TryGetProperty(...) ? v.GetBoolean() : null), but nothing asserted on it: reverting + // just that one line to TryGetProperty(...) && v.GetBoolean() - which still compiles, + // since bool converts to bool? - left the entire 1083-test suite green. That mutation + // defaults an absent "validated" to false instead of leaving it null, exactly the + // fabrication this file exists to catch. + Assert.IsNull(result.Validated, "the feature command never sends validated - the property must stay null, not fabricate false"); + } + + /// + /// A member present with a JSON null reads as absent rather than throwing. + /// + /// + /// TryGetProperty answers true for "ledger_index": null, and the + /// GetUInt64()/GetBoolean() that followed raised JsonException — the + /// whole response failed over a member the feature handler does not even emit. A + /// node saying null is saying it has no value, which is what null means on this side too. + /// + [TestMethod] + public void Deserialize_ServerFeatures_ExplicitJsonNulls_ReadAsAbsent() + { + ServerFeatures result = JsonSerializer.Deserialize( + """{"ledger_index":null,"validated":null,"ledger_hash":null}""", Options); + + Assert.IsNotNull(result); + Assert.IsNull(result.LedgerIndex); + Assert.IsNull(result.Validated); + Assert.IsNull(result.LedgerHash); + + // A real value still reads as itself - the guard must not swallow everything. + ServerFeatures present = JsonSerializer.Deserialize( + """{"ledger_index":9,"validated":true,"ledger_hash":"ABC"}""", Options); + + Assert.AreEqual(9ul, present.LedgerIndex); + Assert.AreEqual(true, present.Validated); + Assert.AreEqual("ABC", present.LedgerHash); + } + + /// + /// A number that cannot be a reads as absent instead of failing the + /// whole response. + /// + /// + /// Checking ValueKind == Number is not sufficient: GetUInt64 still raises + /// FormatException for a negative value or one past ulong.MaxValue. Both are + /// malformed rather than meaningful, and neither is worth losing the rest of the response + /// over. + /// + [TestMethod] + public void Deserialize_ServerFeatures_OutOfRangeLedgerIndex_ReadsAsAbsent() + { + foreach (string value in new[] { "-1", "18446744073709551616", "1.5" }) + { + ServerFeatures result = JsonSerializer.Deserialize( + "{\"ledger_index\":" + value + "}", Options); + + Assert.IsNotNull(result, "ledger_index of " + value + " must not fail the whole response"); + Assert.IsNull(result.LedgerIndex, "ledger_index of " + value + " cannot be a ulong - it must read as absent, not throw"); + } + } + + // rippled NetworkOpsImp::subLedger gates ledger_index/reserve_base/reserve_inc on + // ledgerMaster_.getValidatedLedger() returning non-null; a node with no validated ledger + // yet (e.g. just started) sends none of them in the subscribe command's own reply. + private const string SubscribeReplyNoValidatedLedgerYet = """ + { + "status": "success", + "type": "response" + } + """; + + [TestMethod] + public void Deserialize_LedgerStreamResponse_NoValidatedLedger_FieldsAreNull() + { + LedgerStreamResponse result = JsonSerializer.Deserialize(SubscribeReplyNoValidatedLedgerYet, Options); + + Assert.IsNotNull(result); + Assert.IsNull(result.LedgerIndex, "subLedger omits ledger_index with no validated ledger yet - must not fabricate 0"); + Assert.IsNull(result.ReserveBase, "subLedger omits reserve_base with no validated ledger yet - must not fabricate 0"); + Assert.IsNull(result.ReserveInc, "subLedger omits reserve_inc with no validated ledger yet - must not fabricate 0"); + Assert.IsNull(result.FeeBase, "subLedger omits fee_base with no validated ledger yet - must not fabricate 0"); + Assert.IsNull(result.LedgerTime, "subLedger omits ledger_time with no validated ledger yet - must not fabricate 0"); + Assert.IsNull(result.FeeRef, "subLedger emits fee_ref only when XRPFees is disabled, and never outside the validated-ledger gate - must not fabricate 0"); + Assert.IsNull(result.TxnCount, "subLedger never emits txn_count on this path at all - must not fabricate 0"); + } + + [TestMethod] + public void Serialize_LedgerStreamResponse_NoValidatedLedger_OmitsFields() + { + LedgerStreamResponse result = JsonSerializer.Deserialize(SubscribeReplyNoValidatedLedgerYet, Options); + + string output = JsonSerializer.Serialize(result, Options); + using JsonDocument doc = JsonDocument.Parse(output); + + Assert.IsFalse(doc.RootElement.TryGetProperty("ledger_index", out _), "re-serialized output must not fabricate ledger_index the node never sent"); + Assert.IsFalse(doc.RootElement.TryGetProperty("reserve_base", out _), "re-serialized output must not fabricate reserve_base the node never sent"); + Assert.IsFalse(doc.RootElement.TryGetProperty("reserve_inc", out _), "re-serialized output must not fabricate reserve_inc the node never sent"); + Assert.IsFalse(doc.RootElement.TryGetProperty("fee_base", out _), "re-serialized output must not fabricate fee_base the node never sent"); + Assert.IsFalse(doc.RootElement.TryGetProperty("fee_ref", out _), "re-serialized output must not fabricate fee_ref the node never sent"); + Assert.IsFalse(doc.RootElement.TryGetProperty("ledger_time", out _), "re-serialized output must not fabricate ledger_time the node never sent"); + Assert.IsFalse(doc.RootElement.TryGetProperty("txn_count", out _), "re-serialized output must not fabricate txn_count the node never sent"); + } + + // rippled NetworkOpsImp::pubValidation sets ledger_index only when the underlying + // STValidation carries the optional sfLedgerSequence field - a partial validation missing + // that field sends no ledger_index at all. + private const string ValidationReceivedWithoutLedgerSequence = """ + { + "type": "validationReceived", + "flags": 2147483649, + "full": false, + "ledger_hash": "EC02890710AAA2B71221B0D560CFB22D64317C07B7406B02959AD84BAD33E602", + "master_key": "nHUon2tpyJEHHYGmxqeGu37cvPYHzrMtUNQFVdCgGNvEkjmCpTqK", + "signature": "3045022100E199B55643F66BC6B37DBC5E185321CF952FD35D13D9E8001EB2564FFB94A07602201746C9A4F7A93647131A2DEB03B76F05E426EC67A5A27D77F4FF2603B9A528E6", + "signing_time": 515115322, + "validation_public_key": "n94Gnc6svmaPPRHUAyyib1gQUov8sYbjLoEwUBYPH39qHZXuo8ZT" + } + """; + + [TestMethod] + public void Deserialize_ValidationStream_NoLedgerSequence_LedgerIndexIsNull() + { + ValidationStream result = JsonSerializer.Deserialize(ValidationReceivedWithoutLedgerSequence, Options); + + Assert.IsNotNull(result); + Assert.IsNull(result.LedgerIndex, "pubValidation omits ledger_index when sfLedgerSequence is absent - must not fabricate 0"); + } + + [TestMethod] + public void Serialize_ValidationStream_NoLedgerSequence_OmitsLedgerIndex() + { + ValidationStream result = JsonSerializer.Deserialize(ValidationReceivedWithoutLedgerSequence, Options); + + string output = JsonSerializer.Serialize(result, Options); + using JsonDocument doc = JsonDocument.Parse(output); + + Assert.IsFalse(doc.RootElement.TryGetProperty("ledger_index", out _), "re-serialized output must not fabricate ledger_index the node never sent"); + } + + // rippled NetworkOpsImp::pubLedger guards fee_ref with `if (!rules().enabled(featureXRPFees))`. + // XRPFees is active on mainnet, so no current node sends the member at all - which makes + // this the one omission below that is not an edge case but the everyday shape of the + // ledgerClosed stream. Captured from a real mainnet subscription. + private const string LedgerClosedFromMainnet = """ + { + "type": "ledgerClosed", + "fee_base": 10, + "ledger_hash": "1BF9F0D8B2C1F94BF9C69AC1E2A34DEE1AAB68A9D1CDBD6B9E7EF0A5C0C0F3E1", + "ledger_index": 106384960, + "ledger_time": 837719525, + "network_id": 0, + "reserve_base": 1000000, + "reserve_inc": 200000, + "txn_count": 42, + "validated_ledgers": "32570-106384960" + } + """; + + [TestMethod] + public void Deserialize_LedgerStream_XrpFeesEnabled_FeeRefIsNull() + { + LedgerStream result = JsonSerializer.Deserialize(LedgerClosedFromMainnet, Options); + + Assert.IsNotNull(result); + Assert.IsNull(result.FeeRef, "XRPFees is active on mainnet, so pubLedger never sends fee_ref - the property must stay null, not fabricate 0"); + Assert.AreEqual(10u, result.FeeBase, "fee_base is unconditional in pubLedger and must still round-trip"); + Assert.AreEqual(42u, result.TxnCount, "txn_count is unconditional in pubLedger, unlike the subLedger reply"); + } + + [TestMethod] + public void Serialize_LedgerStream_XrpFeesEnabled_OmitsFeeRef() + { + LedgerStream result = JsonSerializer.Deserialize(LedgerClosedFromMainnet, Options); + + string output = JsonSerializer.Serialize(result, Options); + using JsonDocument doc = JsonDocument.Parse(output); + + Assert.IsFalse(doc.RootElement.TryGetProperty("fee_ref", out _), "re-serialized output must not fabricate fee_ref into every mainnet ledgerClosed event"); + Assert.IsTrue(doc.RootElement.TryGetProperty("fee_base", out _), "fee_base was sent by the node and must survive the round-trip"); + } + } +} diff --git a/Tests/Xrpl.Tests/Models/TestUNullabilityConformance.cs b/Tests/Xrpl.Tests/Models/TestUNullabilityConformance.cs new file mode 100644 index 00000000..3cd2e0cd --- /dev/null +++ b/Tests/Xrpl.Tests/Models/TestUNullabilityConformance.cs @@ -0,0 +1,290 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json.Serialization; + +using Xrpl.Client.Json.Converters; +using Xrpl.Models.Transaction; +using Xrpl.Models.Transactions; + +namespace Xrpl.Tests.Models.Tests +{ + /// + /// Holds the models to what the protocol says can be absent. A non-nullable CLR property cannot + /// express absence, so it re-serializes as a zero the node never sent — which is the whole + /// defect this level exists to remove. + /// + /// + /// Two rules, and the second is broader than the first on purpose. rippled's requirement flag + /// describes the ledger object; the same models also carry PreviousFields, + /// FinalFields and NewFields, which are partial projections — PreviousFields + /// holds only the members a transaction changed, so even a Required field can be missing there. + /// + [TestClass] + public class TestUNullabilityConformance + { + private static Dictionary Models() + { + FieldInfo field = typeof(TestULedgerEntryFieldsConformance) + .GetField("Models", BindingFlags.NonPublic | BindingFlags.Static); + + // Reached by name, so a rename over there would otherwise surface here as a + // NullReferenceException from an unrelated-looking test rather than as the real cause. + Assert.IsNotNull( + field, + "TestULedgerEntryFieldsConformance no longer has a private static 'Models' field; " + + "this test reads it by name and has to be pointed at the new one."); + return (Dictionary)field.GetValue(null); + } + + private static PropertyInfo FindProperty(Type model, string protocolField) + { + foreach (PropertyInfo property in model.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + JsonPropertyNameAttribute name = property.GetCustomAttribute(); + string mapped = name?.Name ?? property.Name; + if (string.Equals(mapped, protocolField, StringComparison.Ordinal)) + { + return property; + } + } + + return null; + } + + private static bool CannotExpressAbsence(PropertyInfo property) + { + Type type = property.PropertyType; + return type.IsValueType && Nullable.GetUnderlyingType(type) is null; + } + + /// + /// A field rippled declares Optional or Default must map to a property that can be absent. + /// Authoritative: the requirement comes from the vendored ledger_entries.macro. + /// + [TestMethod] + public void TestUOptionalProtocolFieldsMapToNullableProperties() + { + Dictionary> formats = + RippledLedgerEntryFormats.Parse(); + Dictionary models = Models(); + List offenders = new List(); + + foreach (KeyValuePair pair in models.OrderBy(p => p.Key, StringComparer.Ordinal)) + { + if (!formats.TryGetValue(pair.Key, out Dictionary fields)) + { + continue; + } + + foreach (KeyValuePair field in fields) + { + if (field.Value == RippledLedgerEntryFormats.Requirement.Required) + { + continue; + } + + PropertyInfo property = FindProperty(pair.Value, field.Key); + if (property is not null && CannotExpressAbsence(property)) + { + offenders.Add($"{pair.Key}.{field.Key} is {field.Value} but {property.PropertyType.Name} cannot be absent"); + } + } + } + + Assert.AreEqual( + 0, + offenders.Count, + "a field the protocol allows to be absent must not re-serialize as a default:" + + Environment.NewLine + string.Join(Environment.NewLine, offenders)); + } + + /// + /// Every value-typed property of a ledger-entry model must be nullable, whatever the + /// protocol says about the object itself. + /// + /// + /// Broader than the rule above because these models double as the contents of + /// PreviousFields/FinalFields/NewFields. PreviousFields carries only what a transaction + /// changed, so any member can be missing there and a non-nullable property fabricates a + /// value for it — that is where the 156 invented members on a ten-entry account_tx came from. + /// + [TestMethod] + public void TestULedgerEntryPropertiesCanAllExpressAbsence() + { + List offenders = new List(); + + foreach (KeyValuePair pair in Models().OrderBy(p => p.Key, StringComparer.Ordinal)) + { + foreach (PropertyInfo property in pair.Value.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (property.GetCustomAttribute() is not null) + { + continue; + } + + if (CannotExpressAbsence(property)) + { + offenders.Add($"{pair.Key}.{property.Name} : {property.PropertyType.Name}"); + } + } + } + + Assert.AreEqual( + 0, + offenders.Count, + "these appear in PreviousFields/FinalFields, where absence is normal:" + + Environment.NewLine + string.Join(Environment.NewLine, offenders)); + } + + /// + /// rippled TransactionType name -> the response model that carries its fields, built from + /// the one place that registry actually exists at runtime. + /// + /// + /// There is no ready-made "tx type -> model" table in the repo ( + /// compares against - a table + /// against a table, never touching a C# model). The only place a transaction name actually + /// resolves to a model is the switch in , + /// which uses to deserialize every tx/account_tx/ledger + /// response. Guessing the model from "<TxName>Response" by convention instead breaks + /// silently on the two names that do not follow it - Clawback -> ClawBackResponse, + /// AMMClawback -> AMMClawBackResponse - so this goes through the converter's own switch + /// and fails outright if a type declares has no response + /// model wired into it, the same way a newly added ledger object would fail + /// instead of being skipped silently. + /// + private static Dictionary TransactionResponseModels() + { + Type unknownType = typeof(TransactionResponseConverter) + .GetNestedType("TransactionResponseUnknown", BindingFlags.NonPublic); + Assert.IsNotNull(unknownType, "TransactionResponseConverter no longer has a TransactionResponseUnknown sentinel - update this test's detection of unmapped types."); + + Dictionary models = new Dictionary(StringComparer.Ordinal); + List missing = new List(); + + foreach (Xrpl.BinaryCodec.Types.TransactionType transactionType in TxFormat.Formats.Keys) + { + string name = transactionType.ToString(); + object instance = TransactionResponseConverter.Create(name); + Type model = instance.GetType(); + if (model == unknownType) + { + missing.Add(name); + continue; + } + + models[name] = model; + } + + Assert.AreEqual( + 0, + missing.Count, + "TxFormat.Formats declares a transaction type with no response model registered in " + + "TransactionResponseConverter.Create - it would silently fall through to the " + + "unknown-type sentinel on every real response:" + + Environment.NewLine + string.Join(Environment.NewLine, missing)); + + return models; + } + + /// + /// One documented exception, on the same footing as NodeBase.LedgerEntryType (see + /// TestULedgerEntryFieldsConformance): a property the blanket scan flags but that is + /// not actually a case of the defect this test exists to catch. + /// + /// + /// is the wire discriminator + /// reads to pick which response subtype to + /// construct in the first place - by the time this property is populated through the real + /// deserialization path it can never be absent, unlike a PreviousFields-style reduced + /// projection. Declared on , it is + /// shared verbatim with - the type actually on the signing + /// path. Making it nullable would force the interface's declared type to change, which + /// cascades into TransactionRequest and everywhere a transaction gets built and signed for + /// submission, for a property that cannot legitimately go missing on the response side. That + /// is a materially different risk profile from the 21 other properties this test fixed + /// (each scoped to its own single-transaction-type interface), so it stays as a named + /// exception instead of being converted. + /// + private static bool IntentionallyNonNullable(PropertyInfo property) + { + return property.DeclaringType == typeof(TransactionResponse) + && property.Name == nameof(TransactionResponse.TransactionType); + } + + /// + /// Every value-typed property of a transaction response model must be nullable, whatever + /// TxFormat says about the transaction the model represents. + /// + /// + /// Response models are what the node actually sends back for tx/account_tx/ledger + /// transactions, and nested transactions (e.g. Batch.RawTransactions) reuse them too, so a + /// non-nullable property fabricates a value the node never sent - the same defect class + /// covers for ledger entries. + /// + /// Grouped by (DeclaringType, PropertyName) rather than by (model, property): + /// most of these properties live on the shared + /// base and would otherwise report once per every derived model that inherits them, which + /// both inflates the apparent defect count and buries genuinely model-specific offenders + /// under duplicate noise. DeclaringType is the one physical place a fix has to land. + /// + /// + [TestMethod] + public void TestUTransactionResponsePropertiesCanAllExpressAbsence() + { + Dictionary models = TransactionResponseModels(); + + Dictionary<(Type DeclaringType, string PropertyName), (PropertyInfo Property, HashSet Models)> offenders = + new Dictionary<(Type, string), (PropertyInfo, HashSet)>(); + + foreach (KeyValuePair pair in models.OrderBy(p => p.Key, StringComparer.Ordinal)) + { + foreach (PropertyInfo property in pair.Value.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (property.GetCustomAttribute() is not null) + { + continue; + } + + if (!CannotExpressAbsence(property)) + { + continue; + } + + if (IntentionallyNonNullable(property)) + { + continue; + } + + (Type DeclaringType, string PropertyName) key = (property.DeclaringType, property.Name); + if (!offenders.TryGetValue(key, out (PropertyInfo Property, HashSet Models) entry)) + { + entry = (property, new HashSet(StringComparer.Ordinal)); + } + + entry.Models.Add(pair.Key); + offenders[key] = entry; + } + } + + int pairCount = offenders.Values.Sum(entry => entry.Models.Count); + + List lines = offenders + .OrderBy(o => o.Key.DeclaringType.FullName, StringComparer.Ordinal) + .ThenBy(o => o.Key.PropertyName, StringComparer.Ordinal) + .Select(o => $"{o.Key.DeclaringType.FullName}.{o.Key.PropertyName} : {o.Value.Property.PropertyType.Name} ({o.Value.Models.Count} models)") + .ToList(); + + Assert.AreEqual( + 0, + offenders.Count, + $"these appear in transaction responses returned by the node, where absence is normal " + + $"({offenders.Count} unique properties covering {pairCount} model x property pairs):" + + Environment.NewLine + string.Join(Environment.NewLine, lines)); + } + } +} diff --git a/Tests/Xrpl.Tests/Models/TestUOutgoingShapesCarryNoCapture.cs b/Tests/Xrpl.Tests/Models/TestUOutgoingShapesCarryNoCapture.cs new file mode 100644 index 00000000..caf77db9 --- /dev/null +++ b/Tests/Xrpl.Tests/Models/TestUOutgoingShapesCarryNoCapture.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json.Serialization; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Models.Methods; +using Xrpl.Models.Transactions; + +namespace XrplTests.Xrpl.Models +{ + /// + /// Unknown-field capture belongs on shapes read off a node, never on shapes sent to one. + /// + /// + /// The rule is not cosmetic. A type reachable from a request or a transaction is round-tripped + /// back onto the wire, so a member captured from one node's response rides out inside a + /// transaction the user never wrote - and StObject.FromJson passes signingOnly + /// only to the top level, so a nested unknown member reaches the displayed tx_json but + /// not the signed blob. Show one, sign another. + /// + /// This is not hypothetical: Common.PathStep (reaching Payment.Paths), + /// AuthAccount (AMMBid) and the AuthorizeCredential* pair + /// (DepositPreauth) all carried capture until a review caught it. They were missed + /// because the exclusion list was written by type name, while the property that matters is + /// reachability from the request graph - which no naming convention expresses. + /// + /// + /// Known limitation: properties typed as object are opaque to this walk. Nothing in the + /// request graph is shaped that way today, but a future one would pass unchecked. + /// + /// + [TestClass] + public class TestUOutgoingShapesCarryNoCapture + { + /// + /// Whether instances of this type capture unknown members - declared here or inherited. + /// + /// + /// Deliberately not . Capture arrives by inheritance + /// far more often than by declaration: 47 method models get it from + /// alone, and the defect that prompted this test was + /// exactly that - the path step deriving from that base. A DeclaredOnly version of + /// this check stayed green with the defect reintroduced. + /// + private static bool CarriesCapture(Type type) => + type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Any(p => p.GetCustomAttribute(inherit: true) is not null); + + /// + /// Every model type a property type can hold, unwrapping arrays and generics to any depth. + /// + /// + /// Recursive on purpose. Payment.Paths is List<List<PathStep>>: peeling + /// one level yields List<PathStep>, which lives outside Xrpl.Models and gets + /// discarded, so PathStep is never reached. A single-level version of this walk passed + /// while PathStep carried capture - the very defect that prompted this test. + /// + private static IEnumerable Unwrap(Type type) + { + if (type.IsArray) + { + Type element = type.GetElementType(); + if (element is not null) + { + foreach (Type inner in Unwrap(element)) + { + yield return inner; + } + } + yield break; + } + + if (type.IsGenericType) + { + foreach (Type argument in type.GetGenericArguments()) + { + foreach (Type inner in Unwrap(argument)) + { + yield return inner; + } + } + yield break; + } + + yield return type; + } + + /// + /// Every request class and every outgoing transaction, as the walk's starting points. + /// + /// + /// , not : response types + /// implement Common too, so rooting the walk there drags in transaction metadata - the walk + /// then reaches ModifiedNode.FinalFields and reports BaseLedgerEntry, which is + /// a response shape doing exactly what it should. Confirmed by running it both ways: Common + /// gives 207 roots and one false positive, Request gives 124 roots and none. + /// + private static IEnumerable Roots(Assembly assembly) => + assembly.GetTypes().Where(t => + t.IsClass + && !t.IsAbstract + && t.Namespace is not null + && t.Namespace.StartsWith("Xrpl.Models", StringComparison.Ordinal) + && (t.Name.EndsWith("Request", StringComparison.Ordinal) + || typeof(ITransactionRequest).IsAssignableFrom(t))); + + [TestMethod] + public void TestUNothingReachableFromARequestCapturesUnknownFields() + { + Assembly assembly = typeof(AccountInfoRequest).Assembly; + + Type[] roots = Roots(assembly).ToArray(); + // A walk that finds nothing proves nothing: this guards the discovery itself, so a + // renamed interface or namespace fails loudly instead of turning the test green. + Assert.IsTrue(roots.Length > 100, + $"the walk found only {roots.Length} roots, against 124 when this was written - the request graph cannot have shrunk that far, so the discovery is broken rather than the models being clean"); + + HashSet seen = new HashSet(); + Stack pending = new Stack(roots); + Dictionary offenders = new Dictionary(); + + while (pending.Count > 0) + { + Type current = pending.Pop(); + if (!seen.Add(current)) + { + continue; + } + + if (CarriesCapture(current)) + { + offenders[current] = current.FullName; + } + + foreach (PropertyInfo property in current.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (property.GetIndexParameters().Length > 0) + { + continue; + } + + foreach (Type candidate in Unwrap(property.PropertyType)) + { + if (candidate.IsClass + && candidate.Namespace is not null + && candidate.Namespace.StartsWith("Xrpl.Models", StringComparison.Ordinal) + && !seen.Contains(candidate)) + { + pending.Push(candidate); + } + } + } + } + + Assert.AreEqual(0, offenders.Count, + "these shapes are reachable from a request or a transaction and carry [JsonExtensionData]. " + + "A member captured off a response would ride back out inside an outgoing transaction, " + + "and signingOnly does not reach nested objects - so it would show in tx_json and be absent " + + "from the signed blob:\n " + + string.Join("\n ", offenders.Values.OrderBy(n => n, StringComparer.Ordinal))); + } + } +} diff --git a/Tests/Xrpl.Tests/Models/TestUPathStep.cs b/Tests/Xrpl.Tests/Models/TestUPathStep.cs index 8e565c06..4f2ef96a 100644 --- a/Tests/Xrpl.Tests/Models/TestUPathStep.cs +++ b/Tests/Xrpl.Tests/Models/TestUPathStep.cs @@ -4,8 +4,8 @@ using System.Text.Json; using Xrpl.Client.Json; +using Xrpl.Models.Common; using Xrpl.Models.Enums; -using Xrpl.Models.Methods; using Xrpl.Models.Transactions; namespace XrplTests.Xrpl.Models @@ -28,7 +28,7 @@ public void TestUPathStepTypeDeserializesAsFlags() // shape of mainnet tx 1D813B78FC55ABF9054AEBD2AF9DD7C90361F9985B7897E8E9A592D63BF0CC43 string json = @"{""currency"":""4249547800000000000000000000000000000000"",""issuer"":""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"",""type"":48}"; - Path step = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + PathStep step = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); Assert.AreEqual(PathStepType.Currency | PathStepType.Issuer, step.Type); Assert.IsTrue(step.Type.Value.HasFlag(PathStepType.Issuer)); @@ -41,7 +41,7 @@ public void TestUPathStepMptTypeDeserializesAsFlags() { string json = @"{""mpt_issuance_id"":""" + MptIssuanceId + @""",""issuer"":""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"",""type"":96}"; - Path step = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + PathStep step = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); Assert.AreEqual(MptIssuanceId, step.MPTokenIssuanceID); Assert.AreEqual(PathStepType.MPTokenIssuanceID | PathStepType.Issuer, step.Type); @@ -51,7 +51,7 @@ public void TestUPathStepMptTypeDeserializesAsFlags() [TestCategory("TestU")] public void TestUPathStepTypeStaysNumericOnTheWire() { - Path step = new Path + PathStep step = new PathStep { CurrencyCode = "4249547800000000000000000000000000000000", Issuer = "rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3", @@ -68,7 +68,7 @@ public void TestUPathStepTypeStaysNumericOnTheWire() public void TestUPathStepUndeclaredTypeBitSurvives() { // a future protocol bit the enum does not name must not break deserialization - Path step = JsonSerializer.Deserialize(@"{""type"":176}", XrplJsonOptions.Default); + PathStep step = JsonSerializer.Deserialize(@"{""type"":176}", XrplJsonOptions.Default); Assert.AreEqual(176u, (uint)step.Type.Value); Assert.IsTrue(step.Type.Value.HasFlag(PathStepType.Issuer)); @@ -82,7 +82,7 @@ public void TestUPathStepIgnoresLegacyTypeHex() // model; a response from an ancient server must still deserialize, with the key ignored string json = @"{""currency"":""USD"",""issuer"":""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"",""type"":48,""type_hex"":""0000000000000030""}"; - Path step = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + PathStep step = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); Assert.AreEqual(PathStepType.Currency | PathStepType.Issuer, step.Type); Assert.AreEqual("USD", step.CurrencyCode); @@ -120,7 +120,7 @@ private static Dictionary Step(params (string Key, object Value) [TestCategory("TestU")] public void TestUPathStepWithoutTypeIsNull() { - Path step = JsonSerializer.Deserialize(@"{""account"":""rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn""}", XrplJsonOptions.Default); + PathStep step = JsonSerializer.Deserialize(@"{""account"":""rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn""}", XrplJsonOptions.Default); Assert.IsNull(step.Type); } diff --git a/Tests/Xrpl.Tests/Models/TestUPaymentDeliverMaxRoundTrip.cs b/Tests/Xrpl.Tests/Models/TestUPaymentDeliverMaxRoundTrip.cs new file mode 100644 index 00000000..cc23fb1f --- /dev/null +++ b/Tests/Xrpl.Tests/Models/TestUPaymentDeliverMaxRoundTrip.cs @@ -0,0 +1,170 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System.Text.Json; + +using Xrpl.Client.Json; +using Xrpl.Models.Transactions; + +// Level 3, Task 3 of the raw-response initiative: PaymentResponse always wrote "Amount" back out, +// even for a transaction the node reported under "DeliverMax" (API v2). That is not a lost field — +// it is a substitution for a different, only superficially-equivalent protocol field name. A +// reconciliation screen that shows the signer "what is being signed" would show a field the node +// never sent. Fixtures below are real mainnet captures (or the same transaction reshaped to the +// API v1 wire form, the way TestUAccountTransactionsEnvelope and TestUBaseTransactionResponseFields +// already do): tx E08D6E9754025BA2534A78707605E0601F03ACE063687A0CA1BDDACFCD1698C7 (v1/v2 IOU form) +// and tx AB9D77240EE7414006F979CD8AF43BEAF9EC510F0E99DBFE7A2156BFB7DB56B6 (v2 XRP-drops form, from +// account rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh's account_tx). +namespace XrplTests.Xrpl.Models +{ + [TestClass] + public class TestUPaymentDeliverMaxRoundTrip + { + private static readonly JsonSerializerOptions Options = XrplJsonOptions.Default; + + private const string PaymentResponseV2DeliverMax = """ + { + "Account": "rEPak6n2CEsQmowqsTMnkooskcLaGW9MzE", + "DeliverMax": "2", + "Destination": "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + "DestinationTag": 304200, + "Fee": "11", + "Flags": 0, + "LastLedgerSequence": 106227746, + "Sequence": 98882928, + "SigningPubKey": "ED4C767651D1B94D9F1EAA11119442F8228D473E0778926C49D34D3ACE3AFE08B5", + "TransactionType": "Payment", + "TxnSignature": "1823144FA737B57DB3418069B7D0ED7FFA57E3FA9ED4434F27EA26056B3D2AFA5E0E608D5C3744B250C1376CFA17F8FA22A8CF371442DBA756ABA8BF7D59080F", + "ctid": "C654E7BE00000000", + "date": 839790321, + "ledger_index": 106227646 + } + """; + + private const string PaymentResponseV1Amount = """ + { + "TransactionType": "Payment", + "Account": "r3PDtZSa5LiYp1Ysn1vMuMzB59RzV3W9QH", + "Destination": "r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59", + "Amount": { "currency": "USD", "issuer": "r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59", "value": "1" }, + "Fee": "10", + "Sequence": 88, + "hash": "E08D6E9754025BA2534A78707605E0601F03ACE063687A0CA1BDDACFCD1698C7", + "ledger_index": 348734, + "validated": true + } + """; + + // rippled's `tx` method with api_version: 1 still sends BOTH "Amount" and "DeliverMax" for + // the same transaction - confirmed live against mainnet on the hash above + // (Fixtures/Responses/tx_v1_raw.json). Level 3's binary flag ("came in as DeliverMax: yes/no") + // could only remember one of the two, so the second field silently vanished on round-trip - + // by count not a regression (v1 used to lose DeliverMax, this lost Amount instead), but a + // node that sent two fields getting one back out contradicts this class's whole point. + private const string PaymentResponseV1BothNames = """ + { + "TransactionType": "Payment", + "Account": "r3PDtZSa5LiYp1Ysn1vMuMzB59RzV3W9QH", + "Destination": "r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59", + "Amount": { "currency": "USD", "issuer": "r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59", "value": "1" }, + "DeliverMax": { "currency": "USD", "issuer": "r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59", "value": "1" }, + "Fee": "10", + "Sequence": 88, + "hash": "E08D6E9754025BA2534A78707605E0601F03ACE063687A0CA1BDDACFCD1698C7", + "ledger_index": 348734, + "validated": true + } + """; + + [TestMethod] + public void Serialize_PaymentResponse_V2_RoundTripsDeliverMax_NotAmount() + { + PaymentResponse payment = JsonSerializer.Deserialize(PaymentResponseV2DeliverMax, Options); + Assert.IsNotNull(payment); + Assert.IsNotNull(payment.Amount, "DeliverMax must still populate Amount for callers"); + Assert.AreEqual("2", payment.Amount.Value); + + string output = JsonSerializer.Serialize(payment, Options); + + using JsonDocument doc = JsonDocument.Parse(output); + Assert.IsTrue(doc.RootElement.TryGetProperty("DeliverMax", out JsonElement deliverMax), + "the node sent DeliverMax; the round-trip must send it back under the same name"); + Assert.AreEqual("2", deliverMax.GetString()); + Assert.IsFalse(doc.RootElement.TryGetProperty("Amount", out _), + "must not substitute Amount for a field the node never sent"); + } + + [TestMethod] + public void Serialize_PaymentResponse_V1_RoundTripsAmount_NotDeliverMax() + { + PaymentResponse payment = JsonSerializer.Deserialize(PaymentResponseV1Amount, Options); + Assert.IsNotNull(payment); + Assert.IsNotNull(payment.Amount); + Assert.AreEqual("1", payment.Amount.Value); + + string output = JsonSerializer.Serialize(payment, Options); + + using JsonDocument doc = JsonDocument.Parse(output); + Assert.IsTrue(doc.RootElement.TryGetProperty("Amount", out JsonElement amount), + "the node sent Amount; the round-trip must send it back under the same name"); + Assert.AreEqual("USD", amount.GetProperty("currency").GetString()); + Assert.IsFalse(doc.RootElement.TryGetProperty("DeliverMax", out _), + "must not invent a field the node never sent"); + } + + [TestMethod] + public void Serialize_PaymentResponse_V1_WithBothNames_RoundTripsBoth() + { + PaymentResponse payment = JsonSerializer.Deserialize(PaymentResponseV1BothNames, Options); + Assert.IsNotNull(payment); + Assert.IsNotNull(payment.Amount); + Assert.AreEqual("1", payment.Amount.Value); + + string output = JsonSerializer.Serialize(payment, Options); + + using JsonDocument doc = JsonDocument.Parse(output); + Assert.IsTrue(doc.RootElement.TryGetProperty("Amount", out JsonElement amount), + "the node sent Amount; deserializing under DeliverMax's setter afterwards must not erase it"); + Assert.AreEqual("USD", amount.GetProperty("currency").GetString()); + Assert.IsTrue(doc.RootElement.TryGetProperty("DeliverMax", out JsonElement deliverMax), + "the node also sent DeliverMax; it must not be dropped just because Amount already fired"); + Assert.AreEqual("USD", deliverMax.GetProperty("currency").GetString()); + } + + [TestMethod] + public void Serialize_PaymentResponse_ConstructedByCode_WritesAmount() + { + PaymentResponse payment = new PaymentResponse + { + Account = "rEPak6n2CEsQmowqsTMnkooskcLaGW9MzE", + Destination = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + Amount = "1000000", + }; + + string output = JsonSerializer.Serialize(payment, Options); + + using JsonDocument doc = JsonDocument.Parse(output); + Assert.IsTrue(doc.RootElement.TryGetProperty("Amount", out JsonElement amount), + "an object assembled by application code, not deserialized, defaults to the Amount name"); + Assert.AreEqual("1000000", amount.GetString()); + Assert.IsFalse(doc.RootElement.TryGetProperty("DeliverMax", out _)); + } + + [TestMethod] + public void Serialize_Payment_ConstructedByCode_WritesAmount() + { + Payment payment = new Payment + { + Account = "rEPak6n2CEsQmowqsTMnkooskcLaGW9MzE", + Destination = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + Amount = "1000000", + }; + + string output = JsonSerializer.Serialize(payment, Options); + + using JsonDocument doc = JsonDocument.Parse(output); + Assert.IsTrue(doc.RootElement.TryGetProperty("Amount", out JsonElement amount)); + Assert.AreEqual("1000000", amount.GetString()); + Assert.IsFalse(doc.RootElement.TryGetProperty("DeliverMax", out _)); + } + } +} diff --git a/Tests/Xrpl.Tests/Models/TestUResponseFidelity.cs b/Tests/Xrpl.Tests/Models/TestUResponseFidelity.cs new file mode 100644 index 00000000..7412b10e --- /dev/null +++ b/Tests/Xrpl.Tests/Models/TestUResponseFidelity.cs @@ -0,0 +1,276 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; + +using Xrpl.Client.Json; +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; +using Xrpl.Models.Transactions; + +namespace Xrpl.Tests.Models.Tests +{ + /// + /// Round-trips the live mainnet response corpus (Fixtures/Responses) through + /// deserialize -> serialize and structurally diffs the result against the node's own bytes. + /// + /// + /// Levels 0-3 removed fabricated members, silently dropped members and v1/v2 name + /// substitution from this SDK's response models. Every one of those measurements was taken + /// by hand, with a throwaway console project that got deleted afterwards - so the 156-member + /// fabrication count on a ten-transaction account_tx becoming 0 was a one-time fact, + /// not a standing guarantee. This class is what turns it into one: run on every push, it + /// fails the moment a new nullable-vs-required regression or a removed + /// reintroduces either + /// defect. See Fixtures/Responses/README.md for corpus provenance and + /// plans/2026-08-17-raw-response-level4.md for the design rationale. + /// + /// Two different failure classes, on purpose: + /// + /// + /// + /// Fabricated (added) members - the model serialized something the node never sent. + /// Zero tolerance, no exceptions table: a fabricated member is always a lie about what the + /// node said, which is exactly the defect level 3 spent its effort removing (the last + /// instance was Amount standing in for DeliverMax). + /// + /// + /// Dropped (lost) members - the model does not carry something the node sent. This is + /// a known, bounded limitation of projecting onto typed models rather than a defect on the + /// same footing as a fabrication: a caller who needs full fidelity already has + /// XrplResponse<T>.Raw, the exact bytes the node sent, untouched by any model. + /// Every drop this test accepts is named in with a reason; + /// anything not listed there fails the test. + /// + /// + /// + [TestClass] + public class TestUResponseFidelity + { + private static readonly string ResponsesDirectory = + Path.Combine(AppContext.BaseDirectory, "Fixtures", "Responses"); + + /// + /// Corpus file -> the model type XrplClient actually deserializes that command's + /// result into. Every .json file under Fixtures/Responses must + /// appear here - fails the build on + /// any file this dictionary does not cover, so a fixture added to the corpus without a + /// mapping cannot silently go unchecked. + /// + private static readonly Dictionary Models = new(StringComparer.Ordinal) + { + // tx, JSON mode and binary mode alike. rippled has no dedicated tx response shape of + // its own (see the "todo not found class TxResponse" note in Models/Methods/Tx.cs) - + // a `tx` result's field set (close_time_iso, ctid, hash, ledger_hash, ledger_index, + // meta/meta_blob, tx_json/tx_blob, validated) is exactly what TransactionSummary + // already models for account_tx's per-transaction entries, so IXrplClient.TxV2 reuses + // it verbatim (GRequest in Client/IXrplClient.cs). + ["tx_raw.json"] = typeof(TransactionSummary), + ["tx_binary_raw.json"] = typeof(TransactionSummary), + // api_version: 1. rippled's `tx` method has no dedicated v1 response shape either - a v1 + // result is the transaction's own fields (Account, Amount, Destination, meta, ...) sitting + // directly at the top of `result`, which is exactly what TransactionResponse models (see + // IXrplClient.TxV1, GRequest). TransactionResponse itself + // carries [JsonConverter(typeof(TransactionResponseConverter))] and dispatches on + // TransactionType, so a Payment here deserializes as PaymentResponse - the class this file + // was captured to exercise (see the table below). + ["tx_v1_raw.json"] = typeof(TransactionResponse), + ["account_tx_raw.json"] = typeof(AccountTransactions), + ["account_info_raw.json"] = typeof(AccountInfo), + ["account_objects_raw.json"] = typeof(AccountObjects), + ["ledger_raw.json"] = typeof(LOLedger), + }; + + /// + /// Corpus file -> member path -> why the model does not carry it. Paths use + /// $.name/[index] notation rooted at the command's result. A lost + /// member absent from its file's table fails . + /// + /// + /// The reason must say *why* the field is unmodeled, not restate that it is unmodeled - + /// "no property carries it" is not a reason, it is the finding itself. + /// + private static readonly Dictionary> KnownLostMembers = + new(StringComparer.Ordinal) + { + // Empty, and that is the finding: every member of every captured response now + // survives the round trip. + // + // Read it for exactly that and no more. Since unknown-field capture reached every + // response projection, this test can no longer tell "the model declares a + // property for this field" from "the field fell into UnknownFields and was + // written back out" - deleting a declared property outright leaves the whole + // suite green (verified by mutation, not assumed). What stays guarded is the wire + // contract: nothing the node sent is dropped, and nothing it did not send is + // invented. Guarding the typed surface is a different job, and this table is not + // it. + }; + + /// + /// Guards itself: a corpus file with no entry here would otherwise + /// just be skipped by , silently exempting it + /// from fidelity checking instead of failing loudly. + /// + [TestMethod] + public void TestUEveryCorpusFileHasAModelMapping() + { + string[] corpusFiles = Directory.GetFiles(ResponsesDirectory, "*.json"); + Assert.IsTrue(corpusFiles.Length > 0, $"no .json fixtures found under {ResponsesDirectory}"); + + List unmapped = corpusFiles + .Select(Path.GetFileName) + .Where(name => !Models.ContainsKey(name)) + .OrderBy(name => name, StringComparer.Ordinal) + .ToList(); + + Assert.AreEqual( + 0, + unmapped.Count, + "corpus file(s) added without a Models[] mapping - they would silently skip fidelity " + + "checking entirely: " + string.Join(", ", unmapped)); + } + + /// + /// The core check: for every mapped corpus file, deserialize result into its model + /// and serialize back, then structurally diff against the original result. + /// Fabricated members always fail; dropped members fail unless named in + /// . + /// + [TestMethod] + public void TestUCorpusRoundTripIsFaithful() + { + List failures = new List(); + + foreach (KeyValuePair entry in Models.OrderBy(p => p.Key, StringComparer.Ordinal)) + { + string file = entry.Key; + Type modelType = entry.Value; + string fixturePath = Path.Combine(ResponsesDirectory, file); + + Assert.IsTrue(File.Exists(fixturePath), $"{file}: mapped in Models but the fixture file is missing at {fixturePath}"); + + JsonNode envelope = JsonNode.Parse(File.ReadAllText(fixturePath)); + JsonNode original = envelope?["result"]; + Assert.IsNotNull(original, $"{file}: no top-level \"result\" member - malformed fixture"); + + object model = original.Deserialize(modelType, XrplJsonOptions.Default); + Assert.IsNotNull(model, $"{file}: deserializing result as {modelType.Name} produced null"); + + string roundTrippedJson = JsonSerializer.Serialize(model, modelType, XrplJsonOptions.Default); + JsonNode roundTripped = JsonNode.Parse(roundTrippedJson); + + List added = new List(); + List lost = new List(); + DiffMembers(original, roundTripped, "$", added, lost); + + KnownLostMembers.TryGetValue(file, out Dictionary knownForFile); + knownForFile ??= new Dictionary(StringComparer.Ordinal); + + foreach (string addedPath in added.OrderBy(p => p, StringComparer.Ordinal)) + { + failures.Add($"{file} [{modelType.Name}]: FABRICATED {addedPath} " + + "- the model serialized a member the node never sent"); + } + + foreach (string lostPath in lost.OrderBy(p => p, StringComparer.Ordinal)) + { + if (!knownForFile.ContainsKey(lostPath)) + { + failures.Add($"{file} [{modelType.Name}]: DROPPED {lostPath} " + + "- not present in KnownLostMembers for this file; either the model " + + "should carry it, or the drop needs a documented reason"); + } + } + } + + Assert.AreEqual( + 0, + failures.Count, + "response fidelity regression - fabricated members must never happen, and dropped " + + "members must be named in KnownLostMembers with a reason:" + + Environment.NewLine + string.Join(Environment.NewLine, failures)); + } + + /// + /// Recursively compares two trees rooted at the same logical + /// position and records every member path present on only one side. + /// + /// + /// Deliberately member-presence only, not value equality: XrplJsonOptions round-trips + /// numeric/string representations through several converters (currency amounts, dates, + /// hex blobs) that can legitimately change a leaf's textual form without changing what it + /// means. This test's job is "did a member appear or vanish", not "is every value + /// byte-identical" - value fidelity for callers who need it is what + /// XrplResponse<T>.Raw is for. + /// + private static void DiffMembers(JsonNode original, JsonNode roundTripped, string path, List added, List lost) + { + JsonObject originalObject = original as JsonObject; + JsonObject roundTrippedObject = roundTripped as JsonObject; + if (originalObject is not null || roundTrippedObject is not null) + { + if (originalObject is not null) + { + foreach (KeyValuePair member in originalObject) + { + string childPath = $"{path}.{member.Key}"; + if (roundTrippedObject is not null && roundTrippedObject.TryGetPropertyValue(member.Key, out JsonNode roundTrippedChild)) + { + DiffMembers(member.Value, roundTrippedChild, childPath, added, lost); + } + else + { + lost.Add(childPath); + } + } + } + + if (roundTrippedObject is not null) + { + foreach (KeyValuePair member in roundTrippedObject) + { + if (originalObject is null || !originalObject.ContainsKey(member.Key)) + { + added.Add($"{path}.{member.Key}"); + } + } + } + + return; + } + + JsonArray originalArray = original as JsonArray; + JsonArray roundTrippedArray = roundTripped as JsonArray; + if (originalArray is not null || roundTrippedArray is not null) + { + int originalCount = originalArray?.Count ?? 0; + int roundTrippedCount = roundTrippedArray?.Count ?? 0; + int commonCount = Math.Min(originalCount, roundTrippedCount); + + for (int index = 0; index < commonCount; index++) + { + DiffMembers(originalArray[index], roundTrippedArray[index], $"{path}[{index}]", added, lost); + } + + for (int index = commonCount; index < originalCount; index++) + { + lost.Add($"{path}[{index}]"); + } + + for (int index = commonCount; index < roundTrippedCount; index++) + { + added.Add($"{path}[{index}]"); + } + + return; + } + + // Both sides are leaves (string/number/bool/null) or absent - member presence is + // already resolved by the caller; leaf value equality is out of scope (see remarks). + } + } +} diff --git a/Tests/Xrpl.Tests/Sugar/TestUAmmMath.cs b/Tests/Xrpl.Tests/Sugar/TestUAmmMath.cs new file mode 100644 index 00000000..866c4c1f --- /dev/null +++ b/Tests/Xrpl.Tests/Sugar/TestUAmmMath.cs @@ -0,0 +1,454 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; + +using Xrpl.Sugar; + +namespace XrplTests.Xrpl.Sugar; + +/// +/// AMM deposit and withdrawal arithmetic, checked against what rippled computes - issue #133. +/// +/// +/// The formulas are equations 3 and 7 from rippled's AMMHelpers.cpp, and the point of taking +/// them from there rather than from the widely quoted approximation is measurable: see +/// . +/// +[TestClass] +public class TestUAmmMath +{ + /// + /// The figure from the report, and the reason this class exists. + /// + /// + /// A deposit the size of the pool at a 1% fee. rippled credits 0.41213·T; the formula + /// that circulates for this, T·(√(1 + b·(1 − f/2)/B) − 1), says 0.41244·T. + /// + [TestMethod] + public void TestUASingleAssetDepositMatchesTheNodesFigure() + { + decimal tokens = AmmMath.LPTokensForSingleAssetDeposit( + poolBalance: 1_000_000m, + deposit: 1_000_000m, + lpTokenBalance: 1_000_000m, + tradingFee: 1000); + + decimal asFractionOfT = tokens / 1_000_000m; + + Assert.AreEqual( + 0.41213m, + Math.Round(asFractionOfT, 5), + $"Equation 3 gives 0.41213·T for this pool; got {asFractionOfT}"); + } + + /// + /// The approximation this replaces, shown to be wrong rather than asserted to be. + /// + /// + /// Worth its own test because the two agree closely enough that a spot check does not tell them + /// apart - 0.08% here - and because the error is always in the same direction: the + /// approximation credits more tokens than the node will. + /// + [TestMethod] + public void TestUTheCirculatingApproximationIsWrongOnceThereIsAFee() + { + const decimal Pool = 1_000_000m; + const uint Fee = 1000; // one per cent + + decimal exact = AmmMath.LPTokensForSingleAssetDeposit(Pool, Pool, Pool, Fee) / Pool; + + // T * (sqrt(1 + b*(1 - f/2)/B) - 1), with b = B + decimal f = AmmMath.TradingFeeFraction(Fee); + decimal approximate = AmmMath.Sqrt(1m + (1m - f / 2m)) - 1m; + + Assert.AreEqual(0.41213m, Math.Round(exact, 5)); + Assert.AreEqual(0.41244m, Math.Round(approximate, 5)); + Assert.IsTrue( + approximate > exact, + "The approximation overstates the credit, which is the direction that disappoints a caller."); + } + + /// + /// Without a fee the two agree exactly, which is where the approximation came from. + /// + /// + /// This is what makes the difference easy to miss: the approximation is not a rough model of + /// the wrong thing, it is the right formula with the fee handled loosely, so it is exact + /// wherever there is no fee to handle. + /// + [TestMethod] + public void TestUWithoutAFeeTheTwoAgree() + { + const decimal Pool = 1_000_000m; + + decimal exact = AmmMath.LPTokensForSingleAssetDeposit(Pool, Pool, Pool, tradingFee: 0) / Pool; + decimal approximate = AmmMath.Sqrt(2m) - 1m; + + Assert.AreEqual(Math.Round(approximate, 20), Math.Round(exact, 20)); + } + + /// + /// The auction slot holder trades at a tenth of the pool's fee, and is credited accordingly. + /// + /// + /// One of the two things the report names as making an otherwise correct estimate miss: the + /// node computes the slot holder's deposits and withdrawals at DiscountedFee, so an + /// estimate at the pool's fee is wrong for exactly the account most likely to be doing the + /// estimating. + /// + [TestMethod] + public void TestUTheAuctionSlotHolderIsCreditedAtTheDiscountedFee() + { + const decimal Pool = 1_000_000m; + + Assert.AreEqual(100u, AmmMath.DiscountedTradingFee(1000), "A tenth of the pool's fee."); + + decimal atPoolFee = AmmMath.LPTokensForSingleAssetDeposit(Pool, Pool, Pool, 1000); + decimal atSlotFee = AmmMath.LPTokensForSingleAssetDeposit(Pool, Pool, Pool, AmmMath.DiscountedTradingFee(1000)); + + Assert.IsTrue( + atSlotFee > atPoolFee, + "A smaller fee means more tokens for the same deposit; estimating at the pool's fee shortchanges the slot holder."); + } + + /// + /// A withdrawal costs at least what the same deposit earned, and more once there is a fee. + /// + /// + /// Equations 3 and 7 are separate formulas, and a transcription error in either would most + /// likely show up as this invariant breaking - putting an asset in and taking the same amount + /// straight back out cannot be free, or the pool could be drained by doing it repeatedly. + /// + [TestMethod] + public void TestUARoundTripCostsTheFee() + { + const decimal Pool = 1_000_000m; + const decimal Amount = 100_000m; + const uint Fee = 1000; + + decimal earned = AmmMath.LPTokensForSingleAssetDeposit(Pool, Amount, Pool, Fee); + decimal spent = AmmMath.LPTokensForSingleAssetWithdraw(Pool + Amount, Amount, Pool + earned, Fee); + + Assert.IsTrue( + spent > earned, + $"Depositing and withdrawing the same amount must cost the fee, but earned {earned} and spent {spent}."); + } + + /// + /// Without a fee the round trip is exactly neutral, which is what pins equation 7. + /// + /// + /// + /// Deposit b into a pool of B, then take the same b straight back out of + /// the pool it has become. With no fee to pay, that must return precisely the tokens it earned + /// - the pool ends where it started, so the LP must too. + /// + /// + /// This is here because a mutation survived without it. Equation 7 multiplies by the fee where + /// equation 3 multiplies by 1 − fee - rippled's lpTokensIn calls getFee + /// where lpTokensOut calls feeMult - and swapping the two passed every other test + /// here, including the round-trip inequality, which is too loose to notice. The identity below + /// is not: with the wrong multiplier the two sides stop matching by a wide margin. + /// + /// + [TestMethod] + public void TestUWithoutAFeeTheRoundTripIsExactlyNeutral() + { + const decimal Pool = 1_000_000m; + const decimal Amount = 250_000m; + const decimal Tokens = 1_000_000m; + + decimal earned = AmmMath.LPTokensForSingleAssetDeposit(Pool, Amount, Tokens, tradingFee: 0); + decimal spent = AmmMath.LPTokensForSingleAssetWithdraw( + Pool + Amount, + Amount, + Tokens + earned, + tradingFee: 0); + + Assert.AreEqual( + Math.Round(earned, 18), + Math.Round(spent, 18), + $"With no fee the two equations must invert each other exactly; earned {earned}, spent {spent}."); + } + + [TestMethod] + public void TestUAProportionalDepositIsLimitedByWhicheverAssetRunsOutFirst() + { + decimal tokens = AmmMath.LPTokensForProportionalDeposit( + poolBalance1: 1_000m, + poolBalance2: 4_000m, + deposit1: 100m, // a tenth of the pool + deposit2: 200m, // a twentieth, and therefore the limit + lpTokenBalance: 2_000m); + + Assert.AreEqual(100m, tokens, "frac is the smaller of the two ratios: 200/4000 = 0.05."); + + (decimal asset1, decimal asset2) = AmmMath.AssetsForProportionalDeposit(1_000m, 4_000m, 100m, 200m); + + Assert.AreEqual(50m, asset1, "Only half of what was offered on the first asset is taken."); + Assert.AreEqual(200m, asset2, "All of the limiting one."); + } + + [TestMethod] + public void TestUAProportionalWithdrawReturnsBothSidesAtTheSameFraction() + { + (decimal asset1, decimal asset2) = AmmMath.AssetsForProportionalWithdraw( + poolBalance1: 1_000m, + poolBalance2: 4_000m, + lpTokens: 500m, + lpTokenBalance: 2_000m); + + Assert.AreEqual(250m, asset1); + Assert.AreEqual(1_000m, asset2); + } + + /// + /// The square root keeps more digits than a double one would. + /// + /// + /// The reason the class does its own: carries 15 significant digits and + /// the rest of the arithmetic carries 28, so using it would throw away precision at the one step + /// where the formulas need it most. + /// + [TestMethod] + public void TestUTheSquareRootIsExactToDecimalPrecision() + { + decimal root = AmmMath.Sqrt(2m); + + Assert.IsTrue( + Math.Abs(root * root - 2m) < 0.0000000000000000000000001m, + $"√2 squared came back as {root * root}"); + + double viaDouble = Math.Sqrt(2.0); + Assert.IsTrue( + Math.Abs(root - (decimal)viaDouble) > 0m, + "If this matched the double result exactly there would be no point computing it separately."); + } + + [TestMethod] + public void TestUImpossibleInputsAreRefusedRatherThanReturningNonsense() + { + Assert.ThrowsExactly( + () => AmmMath.LPTokensForSingleAssetDeposit(0m, 10m, 100m, 0)); + Assert.ThrowsExactly( + () => AmmMath.LPTokensForSingleAssetDeposit(100m, -1m, 100m, 0)); + Assert.ThrowsExactly( + () => AmmMath.LPTokensForSingleAssetWithdraw(100m, 101m, 100m, 0)); + Assert.ThrowsExactly( + () => AmmMath.AssetsForProportionalWithdraw(100m, 100m, 101m, 100m)); + } + + /// + /// A fee in the wrong units is refused rather than quietly answered. + /// + /// + /// + /// TradingFee is in units of 1/100 000, and rippled caps it at 1000 - one per cent - in + /// kTradingFeeThreshold. A caller who reaches for basis points or for whole per cent is + /// out by a factor of ten or a hundred, and nothing in the arithmetic notices: at 5000 every + /// intermediate value stays finite and a plausible, wrong number comes back. Only at 100 000 + /// does 1 - fee reach zero and the division fail, and a DivideByZeroException is + /// not what a caller should have to diagnose from. + /// + /// + /// The bound is on the fee itself, so it holds even for an amount of zero - otherwise whether + /// bad input is reported would depend on how much was being deposited. + /// + /// + [TestMethod] + public void TestUAFeeInTheWrongUnitsIsRefused() + { + Assert.AreEqual(1000u, AmmMath.TradingFeeThreshold); + + Assert.ThrowsExactly( + () => AmmMath.LPTokensForSingleAssetDeposit(1_000m, 100m, 1_000m, tradingFee: 5000)); + Assert.ThrowsExactly( + () => AmmMath.LPTokensForSingleAssetWithdraw(1_000m, 100m, 1_000m, tradingFee: 5000)); + Assert.ThrowsExactly( + () => AmmMath.TradingFeeFraction(100_000)); + Assert.ThrowsExactly( + () => AmmMath.DiscountedTradingFee(1001)); + + // Not conditional on there being an amount to compute. + Assert.ThrowsExactly( + () => AmmMath.LPTokensForSingleAssetDeposit(1_000m, 0m, 1_000m, tradingFee: 5000)); + + // And the cap itself is allowed - an off-by-one here would refuse the highest legal pool. + AmmMath.LPTokensForSingleAssetDeposit(1_000m, 100m, 1_000m, AmmMath.TradingFeeThreshold); + } + + /// + /// Equations 3 and 4 invert each other, which is the only cheap way to pin equation 4. + /// + /// + /// rippled derives equation 4 by solving equation 3 for the deposit, so the two must compose + /// to the identity for any input. That derivation runs through a quadratic, and a sign or a + /// factor lost anywhere in it breaks this while leaving a plausible-looking number behind. + /// + [TestMethod] + public void TestUTheDepositEquationsInvertEachOther() + { + const decimal Pool = 1_000_000m; + const decimal Tokens = 1_000_000m; + + foreach (uint fee in new uint[] { 0, 1, 500, 1000 }) + { + foreach (decimal deposit in new[] { 1m, 1_000m, 250_000m, 1_000_000m }) + { + decimal tokens = AmmMath.LPTokensForSingleAssetDeposit(Pool, deposit, Tokens, fee); + decimal back = AmmMath.SingleAssetDepositForLPTokens(Pool, tokens, Tokens, fee); + + Assert.AreEqual( + Math.Round(deposit, 12), + Math.Round(back, 12), + $"Depositing {deposit} at a fee of {fee} earns {tokens}, which should cost {deposit} to buy back - got {back}."); + } + } + } + + /// + /// And equations 7 and 8, the withdrawal pair. + /// + [TestMethod] + public void TestUTheWithdrawEquationsInvertEachOther() + { + const decimal Pool = 1_000_000m; + const decimal Tokens = 1_000_000m; + + foreach (uint fee in new uint[] { 0, 1, 500, 1000 }) + { + foreach (decimal withdraw in new[] { 1m, 1_000m, 250_000m, 900_000m }) + { + decimal tokens = AmmMath.LPTokensForSingleAssetWithdraw(Pool, withdraw, Tokens, fee); + decimal back = AmmMath.SingleAssetWithdrawForLPTokens(Pool, tokens, Tokens, fee); + + Assert.AreEqual( + Math.Round(withdraw, 12), + Math.Round(back, 12), + $"Withdrawing {withdraw} at a fee of {fee} costs {tokens}, which should return {withdraw} - got {back}."); + } + } + } + + /// + /// Redeeming every token empties the pool, whatever the fee. + /// + /// + /// The input where equation 8's denominator comes closest to zero, and the answer is still + /// exact: at t1 = 1 the fraction is (fee - 1)/(fee - 1). Worth its own test + /// because a formula that is merely close would show it here first. + /// + [TestMethod] + public void TestURedeemingEveryTokenTakesTheWholePool() + { + Assert.AreEqual(1_000m, AmmMath.SingleAssetWithdrawForLPTokens(1_000m, 500m, 500m, 1000)); + Assert.AreEqual(1_000m, AmmMath.SingleAssetWithdrawForLPTokens(1_000m, 500m, 500m, 0)); + } + + /// + /// The swap matches the closed form, and the fee is taken before the curve rather than after. + /// + /// + /// Two different things could be called "a 1% fee on a swap of 100": one takes 1 off the input + /// and puts 99 through the curve, the other puts 100 through and takes 1% off the output. + /// rippled does the first, and the two do not agree. + /// + [TestMethod] + public void TestUASwapTakesTheFeeOffTheInputBeforeTheCurve() + { + decimal withoutFee = AmmMath.SwapAssetIn(1_000m, 1_000m, 100m, 0); + Assert.AreEqual(1_000m * 100m / 1_100m, withoutFee, "out = poolOut * in / (poolIn + in)"); + + decimal withFee = AmmMath.SwapAssetIn(1_000m, 1_000m, 100m, 1000); + Assert.AreEqual(1_000m * 99m / 1_099m, withFee, "99 goes through the curve, not 100."); + + decimal feeOnOutput = withoutFee * 0.99m; + Assert.AreNotEqual( + Math.Round(feeOnOutput, 12), + Math.Round(withFee, 12), + "Taking the fee off the output instead gives a different number, and is the natural mistake."); + } + + /// + /// Without a fee the swap leaves the constant product where it found it. + /// + [TestMethod] + public void TestUAFreeSwapPreservesTheInvariant() + { + const decimal In = 1_000m; + const decimal Out = 4_000m; + + decimal received = AmmMath.SwapAssetIn(In, Out, 250m, tradingFee: 0); + + Assert.AreEqual( + Math.Round(In * Out, 8), + Math.Round((In + 250m) * (Out - received), 8), + "k must be unchanged when nothing is charged for the trade."); + } + + /// + /// The two halves of the swap invert each other exactly. + /// + [TestMethod] + public void TestUTheSwapInvertsExactly() + { + foreach (uint fee in new uint[] { 0, 500, 1000 }) + { + decimal received = AmmMath.SwapAssetIn(1_000m, 4_000m, 250m, fee); + decimal cost = AmmMath.SwapAssetOut(1_000m, 4_000m, received, fee); + + Assert.AreEqual( + Math.Round(250m, 12), + Math.Round(cost, 12), + $"At a fee of {fee}, buying back what 250 bought should cost 250 - got {cost}."); + } + } + + /// + /// A constant-product pool cannot be swapped empty, and says so instead of dividing by zero. + /// + [TestMethod] + public void TestUAPoolCannotBeSwappedEmpty() + { + decimal nearly = AmmMath.SwapAssetOut(1_000m, 4_000m, 3_999m, 0); + decimal nearer = AmmMath.SwapAssetOut(1_000m, 4_000m, 3_999.9m, 0); + Assert.IsTrue(nearer > nearly * 9m, $"The cost should climb steeply: {nearly} then {nearer}."); + + Assert.ThrowsExactly( + () => AmmMath.SwapAssetOut(1_000m, 4_000m, 4_000m, 0)); + Assert.ThrowsExactly( + () => AmmMath.SwapAssetOut(1_000m, 4_000m, 4_001m, 0)); + } + + /// + /// The fee bound covers everything that charges a fee, not only what it was written for. + /// + [TestMethod] + public void TestUTheFeeBoundCoversTheSwapAndTheInverses() + { + Assert.ThrowsExactly( + () => AmmMath.SwapAssetIn(1_000m, 1_000m, 100m, tradingFee: 5000)); + Assert.ThrowsExactly( + () => AmmMath.SwapAssetOut(1_000m, 1_000m, 100m, tradingFee: 5000)); + Assert.ThrowsExactly( + () => AmmMath.SingleAssetDepositForLPTokens(1_000m, 100m, 1_000m, tradingFee: 5000)); + Assert.ThrowsExactly( + () => AmmMath.SingleAssetWithdrawForLPTokens(1_000m, 100m, 1_000m, tradingFee: 5000)); + } + + [TestMethod] + public void TestUSwappingAndRedeemingNothingGivesNothing() + { + Assert.AreEqual(0m, AmmMath.SwapAssetIn(1_000m, 1_000m, 0m, 1000)); + Assert.AreEqual(0m, AmmMath.SwapAssetOut(1_000m, 1_000m, 0m, 1000)); + Assert.AreEqual(0m, AmmMath.SingleAssetDepositForLPTokens(1_000m, 0m, 1_000m, 1000)); + Assert.AreEqual(0m, AmmMath.SingleAssetWithdrawForLPTokens(1_000m, 0m, 1_000m, 1000)); + } + + [TestMethod] + public void TestUDepositingNothingEarnsNothing() + { + Assert.AreEqual(0m, AmmMath.LPTokensForSingleAssetDeposit(100m, 0m, 100m, 1000)); + Assert.AreEqual(0m, AmmMath.LPTokensForSingleAssetWithdraw(100m, 0m, 100m, 1000)); + } +} diff --git a/Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs b/Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs index 88391e29..02cade88 100644 --- a/Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs +++ b/Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs @@ -640,7 +640,37 @@ public FeeTestClient(string feeXrp, uint reserveInc, string maxFeeXRP = "5") this.feeCushion = 1.0; } - public Connection connection { get; set; } = null!; + public Connection connection => null!; + public long DroppedStreamMessages => 0; + + public long StaleSessionFramesDropped => 0; + + // Declared rather than inherited: this substitute returns null from connection, and the + // interface defaults forward there. + public long FallbackDispatchedStreamMessages => 0; + + // Declared, never raised: this client answers requests, it does not stream. Being able to + // declare them at all is the point of the events moving onto IXrplClient - a substitute + // client could not carry them while they lived only on Connection. +#pragma warning disable CS0067 + public event OnError OnError; + public event OnWarning OnWarning; + public event OnServerWarning OnServerWarning; + public event OnConnected OnConnected; + public event OnDisconnect OnDisconnect; + public event OnSessionEnded OnSessionEnded; + public event OnPing OnPing; + public event OnLedgerClosed OnLedgerClosed; + public event OnTransaction OnTransaction; + public event OnValidationReceived OnValidationReceived; + public event OnManifestReceived OnManifestReceived; + public event OnPeerStatusChange OnPeerStatusChange; + public event OnConsensusPhase OnConsensusPhase; + public event OnPathFind OnPathFind; + public event OnBookChanges OnBookChanges; + public event OnServerStatus OnServerStatus; + public event Action OnConnectionStatus; +#pragma warning restore CS0067 public double feeCushion { get; set; } public string maxFeeXRP { get; set; } public uint? networkID { get; set; } @@ -660,7 +690,7 @@ public FeeTestClient(string feeXrp, uint reserveInc, string maxFeeXRP = "5") public AccountInfoRequest? LastAccountInfoRequest { get; private set; } public LedgerEntryRequest? LastLedgerEntryRequest { get; private set; } - public Task ServerInfo(ServerInfoRequest request, CancellationToken cancellationToken = default) + public Task> ServerInfo(ServerInfoRequest request, CancellationToken cancellationToken = default) { var info = new ServerInfo { @@ -673,10 +703,10 @@ public Task ServerInfo(ServerInfoRequest request, CancellationToken } } }; - return Task.FromResult(info); + return Task.FromResult(new XrplResponse(info, default, null, null, null, null, false)); } - public Task ServerState(ServerStateRequest request, CancellationToken cancellationToken = default) + public Task> ServerState(ServerStateRequest request, CancellationToken cancellationToken = default) { var state = new ServerState { @@ -688,10 +718,10 @@ public Task ServerState(ServerStateRequest request, CancellationTok } } }; - return Task.FromResult(state); + return Task.FromResult(new XrplResponse(state, default, null, null, null, null, false)); } - public Task ServerFeatures(string feature = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + public Task> ServerFeatures(string feature = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task GetLedgerIndex(CancellationToken cancellationToken = default) => Task.FromResult(100u); public Task GetXrpBalance(string address, CancellationToken cancellationToken = default) => throw new NotSupportedException(); @@ -711,66 +741,70 @@ public Task ServerState(ServerStateRequest request, CancellationTok public Task DisconnectAndWaitAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public bool IsConnected() => throw new NotSupportedException(); - public Task Subscribe(SubscribeRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task Unsubscribe(UnsubscribeRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task Ping(CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task Fee(CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task AccountInfo(AccountInfoRequest request, CancellationToken cancellationToken = default) + public Task> Subscribe(SubscribeRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> Unsubscribe(UnsubscribeRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> Ping(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> Fee(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> AccountInfo(AccountInfoRequest request, CancellationToken cancellationToken = default) { // Honour the token the way a real client does, so tests can assert that a caller's // cancellation reaches autofill instead of being turned into a fee fallback. cancellationToken.ThrowIfCancellationRequested(); AccountInfoCalls++; LastAccountInfoRequest = request; - return Task.FromResult(new AccountInfo { SignerLists = CounterpartySignerLists }); - } - - public Task AccountOffers(AccountOffersRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task AccountCurrencies(AccountCurrenciesRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task AccountLines(AccountLinesRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task AccountChannels(AccountChannelsRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task AccountObjects(AccountObjectsRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task AccountTransactions(AccountTransactionsRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task GatewayBalances(GatewayBalancesRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task NoRippleCheck(NoRippleCheckRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task Ledger(LedgerRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task LedgerClosed(LedgerClosedRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task LedgerCurrent(LedgerCurrentRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task LedgerData(LedgerDataRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task LedgerEntry(LedgerEntryRequest request, CancellationToken cancellationToken = default) + AccountInfo info = new AccountInfo { SignerLists = CounterpartySignerLists }; + return Task.FromResult(new XrplResponse(info, default, null, null, null, null, false)); + } + + public Task> AccountOffers(AccountOffersRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> AccountCurrencies(AccountCurrenciesRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> AccountLines(AccountLinesRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> AccountChannels(AccountChannelsRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> AccountObjects(AccountObjectsRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> AccountTransactions(AccountTransactionsRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GatewayBalances(GatewayBalancesRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> NoRippleCheck(NoRippleCheckRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> Ledger(LedgerRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> LedgerClosed(LedgerClosedRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> LedgerCurrent(LedgerCurrentRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> LedgerData(LedgerDataRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> LedgerEntry(LedgerEntryRequest request, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); LedgerEntryCalls++; LastLedgerEntryRequest = request; if (LedgerEntryThrows) throw new XrplException("entryNotFound"); - return Task.FromResult(new LedgerEntryResponse { Index = request.Index, Node = LoanEntry }); + LedgerEntryResponse response = new LedgerEntryResponse { Index = request.Index, Node = LoanEntry }; + return Task.FromResult(new XrplResponse(response, default, null, null, null, null, false)); } public Task Submit(Dictionary tx, XrplWallet wallet, bool autoFill = true, bool failHard = false, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task Submit(ITransactionRequest tx, XrplWallet wallet, bool autoFill = true, bool failHard = false, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task Tx(TxRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task TxV2(TxRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task BookOffers(BookOffersRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task DepositAuthorized(DepositAuthorizedRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task NFTBuyOffers(NFTBuyOffersRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task NFTSellOffers(NFTSellOffersRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task AccountNFTs(AccountNFTsRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task AmmInfo(AMMInfoRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task Random(CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task AnyRequest(BaseRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task> Request(Dictionary request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task GRequest(R request, CancellationToken cancellationToken = default) where R : BaseRequest => throw new NotSupportedException(); - public Task Simulate(SimulateRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task PathFind(PathFindCreateRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task PathFindClose(PathFindCloseRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task PathFindStatus(PathFindStatusRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task RipplePathFind(RipplePathFindRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task ChannelAuthorize(ChannelAuthorizeRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task ChannelVerify(ChannelVerifyRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task ServerDefinitions(ServerDefinitionsRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task VaultInfo(VaultInfoRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task TransactionEntry(TransactionEntryRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> TxV1(TxRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> TxV2(TxRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> BookOffers(BookOffersRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> DepositAuthorized(DepositAuthorizedRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> NFTBuyOffers(NFTBuyOffersRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> NFTSellOffers(NFTSellOffersRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> NFTInfo(NFTInfoRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> NFTHistory(NFTHistoryRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> AccountNFTs(AccountNFTsRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> AmmInfo(AMMInfoRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> Random(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> AnyRequest(BaseRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task>> Request(Dictionary request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> GRequest(R request, CancellationToken cancellationToken = default) where R : BaseRequest => throw new NotSupportedException(); + public Task> Simulate(SimulateRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> PathFind(PathFindCreateRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> PathFindClose(PathFindCloseRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> PathFindStatus(PathFindStatusRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> RipplePathFind(RipplePathFindRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> ChannelAuthorize(ChannelAuthorizeRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> ChannelVerify(ChannelVerifyRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> ServerDefinitions(ServerDefinitionsRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> VaultInfo(VaultInfoRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> TransactionEntry(TransactionEntryRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public void Dispose() { } #endregion diff --git a/Tests/Xrpl.Tests/Sugar/TestULedgerShapeGuards.cs b/Tests/Xrpl.Tests/Sugar/TestULedgerShapeGuards.cs new file mode 100644 index 00000000..3ee2fe17 --- /dev/null +++ b/Tests/Xrpl.Tests/Sugar/TestULedgerShapeGuards.cs @@ -0,0 +1,131 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System.Threading.Tasks; + +using Xrpl.Client; +using Xrpl.Client.Exceptions; +using Xrpl.Sugar; + +using Xrpl.Tests; + +namespace XrplTests.Xrpl.Sugar; + +/// +/// The sugar helpers that read a ledger take LOLedger.LedgerEntity, which is interface +/// typed, and used to cast it outright. +/// +/// +/// That cast has two failure modes and neither surfaced as a protocol error: a response with no +/// ledger member casts to and faults on the next dereference, while +/// a binary response deserializes to LedgerBinaryEntity and throws +/// . Both mean the same thing to a caller - the node did +/// not return the shape asked for - so both should say so. +/// +[TestClass] +public class TestULedgerShapeGuards +{ + private const string LedgerReplyWithoutLedgerMember = + "{\"id\":__ID__,\"status\":\"success\",\"type\":\"response\",\"api_version\":2," + + "\"result\":{\"ledger_current_index\":106359163,\"validated\":false}}"; + + private const string BinaryLedgerReply = + "{\"id\":__ID__,\"status\":\"success\",\"type\":\"response\",\"api_version\":2," + + "\"result\":{\"ledger\":{\"ledger_data\":\"01AB\",\"transactions\":[]}," + + "\"ledger_index\":106359163,\"validated\":true}}"; + + private static async Task Failure(string scriptedResponse) + { + using ScriptedResponseServer server = new ScriptedResponseServer(scriptedResponse); + using XrplClient client = new XrplClient(server.Url, new XrplClient.ClientOptions { ApiVersion = 2 }); + await client.Connect(); + + try + { + return await Assert.ThrowsExactlyAsync(() => client.GetLedgerIndex()); + } + finally + { + await client.Disconnect(); + } + } + + /// A reply carrying no ledger member fails as a protocol error, not an NRE. + [TestMethod] + public async Task TestUGetLedgerIndexRejectsAReplyWithoutALedgerObject() + { + ValidationException failure = await Failure(LedgerReplyWithoutLedgerMember); + + StringAssert.Contains(failure.Message, "did not include a JSON ledger object"); + } + + private const string ServerStateReplyWithoutValidatedLedger = + "{\"id\":__ID__,\"status\":\"success\",\"type\":\"response\",\"api_version\":2," + + "\"result\":{\"state\":{\"build_version\":\"3.3.0\",\"server_state\":\"full\"}}}"; + + private const string AccountInfoThenIncompleteServerState = + "{\"id\":__ID__,\"status\":\"success\",\"type\":\"response\",\"api_version\":2," + + "\"result\":{\"account_data\":{\"Account\":\"rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd\"," + + "\"Balance\":\"100000000\",\"OwnerCount\":2,\"Sequence\":7}," + + "\"state\":{\"build_version\":\"3.3.0\"}}}"; + + /// + /// The second reserve helper needs its own case: the guard in Balances is a separate + /// one, and a test that only exercises FetchReserveFee stays green when it is removed. + /// + [TestMethod] + public async Task TestUGetXrpFreeBalanceRejectsAServerStateWithoutAValidatedLedger() + { + using ScriptedResponseServer server = new ScriptedResponseServer(AccountInfoThenIncompleteServerState); + using XrplClient client = new XrplClient(server.Url, new XrplClient.ClientOptions { ApiVersion = 2 }); + await client.Connect(); + + try + { + ValidationException failure = await Assert.ThrowsExactlyAsync( + () => client.GetXrpFreeBalance("rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd")); + + StringAssert.Contains(failure.Message, "validated ledger"); + } + finally + { + await client.Disconnect(); + } + } + + /// + /// The reserve helpers read through State.ValidatedLedger and used to test only the + /// leaf values, so a response missing either container faulted on the way to the check rather + /// than reaching it. + /// + [TestMethod] + public async Task TestUFetchReserveFeeRejectsAServerStateWithoutAValidatedLedger() + { + using ScriptedResponseServer server = new ScriptedResponseServer(ServerStateReplyWithoutValidatedLedger); + using XrplClient client = new XrplClient(server.Url, new XrplClient.ClientOptions { ApiVersion = 2 }); + await client.Connect(); + + try + { + XrplException failure = await Assert.ThrowsExactlyAsync(() => client.FetchReserveFee()); + + StringAssert.Contains(failure.Message, "validated ledger"); + } + finally + { + await client.Disconnect(); + } + } + + /// + /// A binary reply deserializes to a different concrete type; the caller is told which, since + /// the fix is on their side - drop binary from the request. + /// + [TestMethod] + public async Task TestUGetLedgerIndexRejectsABinaryLedgerObject() + { + ValidationException failure = await Failure(BinaryLedgerReply); + + StringAssert.Contains(failure.Message, "LedgerBinaryEntity"); + StringAssert.Contains(failure.Message, "binary"); + } +} diff --git a/Tests/Xrpl.Tests/Utils/HasNextPage.cs b/Tests/Xrpl.Tests/Utils/HasNextPage.cs index d0686208..141f6988 100644 --- a/Tests/Xrpl.Tests/Utils/HasNextPage.cs +++ b/Tests/Xrpl.Tests/Utils/HasNextPage.cs @@ -1,14 +1,109 @@ - +// https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/test/utils/hasNextPage.ts -// https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/test/utils/hasNextPage.ts +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System.Text; +using System.Text.Json; + +using Xrpl.Client.Json; +using Xrpl.Models.Subscriptions; +using Xrpl.Utils; namespace XrplTests.Xrpl.Utils { - public class HasNextPage + /// + /// Port of xrpl.js `hasNextPage.ts`. The class name carries the TestU prefix because the CI + /// filter matches on the fully qualified name — as `HasNextPage` it would never have run. + /// + [TestClass] + public class TestUHasNextPage { - public HasNextPage() + private static BaseResponse Envelope(string result) + { + byte[] frame = Encoding.UTF8.GetBytes($"{{\"id\":\"7\",\"status\":\"success\",\"result\":{result}}}"); + ErrorResponse envelope = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); + envelope.AttachFrame(frame); + return envelope; + } + + [TestMethod] + public void TestUMarkerPresentMeansMorePages() + { + Assert.IsTrue(Envelope("{\"marker\":\"AABB\",\"state\":[]}").HasNextPage()); + } + + [TestMethod] + public void TestUMarkerAbsentMeansLastPage() + { + Assert.IsFalse(Envelope("{\"state\":[]}").HasNextPage()); + } + + /// The marker need not be first, and skipping over earlier members must not eat it. + [TestMethod] + public void TestUMarkerFoundAfterNestedMembers() + { + Assert.IsTrue(Envelope( + "{\"state\":[{\"a\":{\"b\":[1,2]}}],\"ledger_index\":9,\"marker\":{\"ledger\":9,\"seq\":1}}") + .HasNextPage()); + } + + /// A `marker` nested inside another member is not the paging marker. + [TestMethod] + public void TestUNestedMarkerIsNotThePagingMarker() + { + Assert.IsFalse(Envelope("{\"state\":[{\"marker\":\"AABB\"}]}").HasNextPage()); + } + + [TestMethod] + public void TestUEnvelopeWithoutResultHasNoNextPage() { + byte[] frame = Encoding.UTF8.GetBytes("{\"id\":\"7\",\"status\":\"success\"}"); + ErrorResponse envelope = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); + envelope.AttachFrame(frame); + + Assert.IsFalse(envelope.HasNextPage()); + } + + /// An envelope built by hand carries no frame, so there is nothing to read. + [TestMethod] + public void TestUEnvelopeWithoutFrameHasNoNextPage() + { + Assert.IsFalse(new ErrorResponse().HasNextPage()); + } + + /// The scanner must bail on a non-object result rather than misread it. + [TestMethod] + public void TestUNonObjectResultHasNoNextPage() + { + Assert.IsFalse(Envelope("[1,2]").HasNextPage()); + Assert.IsFalse(Envelope("\"marker\"").HasNextPage()); + Assert.IsFalse(Envelope("42").HasNextPage()); + Assert.IsFalse(Envelope("null").HasNextPage()); + } + + [TestMethod] + public void TestUEmptyResultObjectHasNoNextPage() + { + Assert.IsFalse(Envelope("{}").HasNextPage()); + } + + /// + /// Works only because the scan goes through ValueTextEquals, which unescapes. Swapping it + /// for a raw byte comparison would pass every other test here and break this one silently. + /// + [TestMethod] + public void TestUEscapedMarkerKeyIsRecognized() + { + Assert.IsTrue(Envelope("{\"\\u006darker\":\"AABB\"}").HasNextPage()); + Assert.IsFalse(Envelope("{\"state\":[{\"\\u006darker\":\"AABB\"}]}").HasNextPage()); + } + + /// Prefix and near-miss keys must not count. + [TestMethod] + public void TestUNearMissKeysAreNotTheMarker() + { + Assert.IsFalse(Envelope("{\"markerX\":1}").HasNextPage()); + Assert.IsFalse(Envelope("{\"marke\":1}").HasNextPage()); } } } - diff --git a/Tests/Xrpl.Tests/Wallet/TestUSignatureResultRoundTrip.cs b/Tests/Xrpl.Tests/Wallet/TestUSignatureResultRoundTrip.cs new file mode 100644 index 00000000..e50a66bf --- /dev/null +++ b/Tests/Xrpl.Tests/Wallet/TestUSignatureResultRoundTrip.cs @@ -0,0 +1,91 @@ +using System; +using System.Text.Json.Nodes; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.BinaryCodec; +using Xrpl.Client.Exceptions; +using Xrpl.Models.Transactions; +using Xrpl.Models.Enums; +using Xrpl.Wallet; + +namespace Xrpl.Tests.Wallet.Tests +{ + /// + /// decodes a signed blob back into a typed transaction, + /// and anything the models do not declare disappears in that step. Signing the result then + /// produces a blob without it — which is how a co-signature got dropped: rippled and the + /// codec both carry CounterpartySignature and SponsorSignature, but no request + /// model declares either, and the co-signing docs recommended exactly this round trip. + /// + [TestClass] + public class TestUSignatureResultRoundTrip + { + private static string EncodeBlob(JsonObject tx) => XrplBinaryCodec.Encode(tx); + + private static JsonObject SignedLoanSetWithCounterpartySignature() + { + JsonObject signature = new JsonObject + { + ["SigningPubKey"] = "ED0000000000000000000000000000000000000000000000000000000000000001", + ["TxnSignature"] = "AABBCC", + }; + + return new JsonObject + { + ["TransactionType"] = "LoanSet", + ["Account"] = "rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd", + ["LoanBrokerID"] = new string('A', 64), + ["Sequence"] = 7L, + ["Fee"] = "12", + ["Flags"] = 0L, + ["SigningPubKey"] = "", + ["CounterpartySignature"] = signature, + }; + } + + /// + /// The blob carries the co-signature, so the round trip must refuse rather than hand back + /// a transaction that silently lost it. + /// + [TestMethod] + public void TestUGetTxRefusesABlobCarryingAFieldNoModelDeclares() + { + string blob = EncodeBlob(SignedLoanSetWithCounterpartySignature()); + SignatureResult result = new SignatureResult(blob, new string('0', 64)); + + ValidationException failure = Assert.ThrowsExactly(() => result.GetTx()); + + StringAssert.Contains(failure.Message, "CounterpartySignature", + "the caller has to be told which member would have been lost, not merely that something was"); + } + + /// + /// The guard must not fire on an ordinary blob — every member of which the models do carry. + /// Without this, a guard that always threw would pass the test above. + /// + [TestMethod] + public void TestUGetTxStillDecodesABlobEveryMemberOfWhichIsModelled() + { + JsonObject tx = new JsonObject + { + ["TransactionType"] = "Payment", + ["Account"] = "rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd", + ["Destination"] = "rBTwLga3i2gz3doX6Gva3MgEV8ZCD8jjah", + ["Amount"] = "1000000", + ["Sequence"] = 7L, + ["Fee"] = "12", + ["Flags"] = 0L, + ["SigningPubKey"] = "", + }; + + SignatureResult result = new SignatureResult(EncodeBlob(tx), new string('0', 64)); + + ITransactionRequest decoded = result.GetTx(); + + Assert.IsNotNull(decoded); + Assert.AreEqual("Payment", decoded.TransactionType.ToString()); + Assert.AreEqual("rP9jPyP5kyvFRb6ZiRghAGw5u8SGAmU4bd", decoded.Account); + } + } +} diff --git a/Tests/Xrpl.Tests/Xrpl.Tests.csproj b/Tests/Xrpl.Tests/Xrpl.Tests.csproj index 95f801ec..e9770dca 100644 --- a/Tests/Xrpl.Tests/Xrpl.Tests.csproj +++ b/Tests/Xrpl.Tests/Xrpl.Tests.csproj @@ -42,6 +42,10 @@ PreserveNewest + + + PreserveNewest + diff --git a/Xrpl/Client/ConnectionSession.cs b/Xrpl/Client/ConnectionSession.cs index da46d6d6..1ac24d81 100644 --- a/Xrpl/Client/ConnectionSession.cs +++ b/Xrpl/Client/ConnectionSession.cs @@ -26,12 +26,35 @@ internal class ConnectionSession public bool IsRetiring { get; private set; } private readonly TaskCompletionSource _completionTcs; - + + private int _endNotified; + + private int _opened; + + /// + /// Whether this session's socket ever finished connecting. + /// + /// + /// The session object is created before ws.Connect() is even called, so its + /// existence says nothing about whether a connection happened. A failed attempt still + /// reaches the close callback and would otherwise be announced as a session that ended - + /// once per retry - when nothing was ever subscribed to lose. + /// + public bool IsOpened => Volatile.Read(ref _opened) == 1; + + /// + /// Records that this session's socket finished connecting. + /// + public void MarkAsOpened() + { + Volatile.Write(ref _opened, value: 1); + } + /// /// Task that completes when this session's OnDisconnected callback has finished. /// public Task Completion => _completionTcs.Task; - + public ConnectionSession(WebSocketClient socket) { SessionId = Interlocked.Increment(ref _sessionIdCounter); @@ -41,6 +64,21 @@ public ConnectionSession(WebSocketClient socket) _completionTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); } + /// + /// Claims the right to announce that this session has ended, for the first caller only. + /// + /// + /// Several paths retire a session and more than one of them can reach the announcement: + /// a deliberate retirement (ChangeServer, fast reconnect) races the socket's own close + /// callback. The consumer must hear about the session once, so the guard belongs to the + /// session rather than to any one of those paths. + /// + /// true for the caller that won; false for every later one. + public bool TryMarkEndNotified() + { + return Interlocked.Exchange(ref _endNotified, value: 1) == 0; + } + /// /// Marks this session as retiring. Callbacks from retiring sessions are ignored. /// diff --git a/Xrpl/Client/Exceptions/TransactionFailedException.cs b/Xrpl/Client/Exceptions/TransactionFailedException.cs new file mode 100644 index 00000000..e6a7b9cb --- /dev/null +++ b/Xrpl/Client/Exceptions/TransactionFailedException.cs @@ -0,0 +1,92 @@ +using System; + +using Xrpl.Models.Methods; + +namespace Xrpl.Client.Exceptions +{ + /// + /// A transaction was submitted and did not succeed. + /// + /// + /// + /// The result code decides what a caller should do next, and the classes differ completely: + /// tem means the request is malformed and must be fixed before it is sent again, + /// tec means it was applied to a ledger and the fee was taken, ter means it may + /// work later. Telling them apart used to mean reading the exception's message, which works + /// until the first transaction whose message contains the substring somewhere else. + /// + /// + /// This derives from and keeps the message it always had, so a + /// catch (RippleException) and anything matching on the text carry on unchanged. What is + /// new is beside the message rather than inside it. + /// + /// + public class TransactionFailedException : RippleException + { + /// + /// The result code, as the node reported it - tecINSUFFICIENT_PAYMENT, + /// temBAD_FEE and so on. + /// + public string EngineResult { get; } + + /// + /// The transaction's hash. + /// + /// + /// Worth having even when the transaction never reached a ledger, but it is the + /// case where it matters: there is something to look up, and + /// showing it is usually the first thing anyone wants to do after a refusal. + /// + public string Hash { get; } + + /// + /// The validated transaction, metadata included - or null when there is none to hand + /// back yet. + /// + /// + /// Absent for a failure the node refused before a ledger, which is what one would expect, + /// and absent as well when a tec was reported from the provisional answer before the + /// ledger closed. Use to tell whether the fee was taken; + /// is there in every case. + /// + public TransactionSummary Result { get; } + + /// + /// Whether the transaction was applied to a ledger - the fee taken, the transaction there + /// to be looked up. + /// + /// + /// + /// The difference is not a detail: applied means the fee is gone and there is something to + /// show, while a refusal before that costs nothing and leaves nothing. The two arrive as + /// the same kind of failure and are not the same event. + /// + /// + /// Read from the result code rather than from whether happens to be + /// here, because the same failure can be reported at two moments: once the transaction is + /// validated, with its metadata, or earlier from the node's provisional answer, when only + /// the code and the hash exist yet. A tec means applied either way, and which of the + /// two moments won a race is not something a caller should have to think about. + /// + /// + /// So can be null while this is true. The hash is + /// present in both cases, and the hash is what an explorer needs. + /// + /// + public bool ReachedLedger => + Result is not null || + (EngineResult is not null && EngineResult.StartsWith("tec", StringComparison.Ordinal)); + + /// The message this exception has always carried, unchanged. + /// The node's result code. + /// The transaction's hash. + /// The validated transaction, or null if there is none. + public TransactionFailedException(string message, string engineResult, string hash, TransactionSummary result = null) + : base(message) + { + EngineResult = engineResult; + Hash = hash; + Result = result; + } + } +} diff --git a/Xrpl/Client/Exceptions/XrplErrorClassifier.cs b/Xrpl/Client/Exceptions/XrplErrorClassifier.cs index f73cfa55..65ad528a 100644 --- a/Xrpl/Client/Exceptions/XrplErrorClassifier.cs +++ b/Xrpl/Client/Exceptions/XrplErrorClassifier.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Nodes; +using Xrpl.Client.Json; using Xrpl.Models.Common; using Xrpl.Models.Subscriptions; @@ -59,7 +60,7 @@ public static XrplErrorInfo Classify(this ErrorResponse response) var error = response.Error?.Trim() ?? string.Empty; var rawMessage = response.ErrorMessage ?? response.ErrorException; - var request = ToJsonObjectSafe(response.Request); + var request = ToJsonObjectSafe(response.RawRequest); var command = GetString(request, "command"); var warnings = ExtractWarnings(response); @@ -755,27 +756,25 @@ private static XrplErrorInfo BuildUnknown( }; } - private static JsonObject? ToJsonObjectSafe(object? request) + /// + /// Parses the request echo straight out of its captured bytes. + /// + /// + /// used to arrive as a (or, for a + /// hand-built response, an arbitrary object serialized to reach one); now it is the raw + /// window captured off the wire, parsed once here + /// instead of once by System.Text.Json to build the element and again by this method. + /// + private static JsonObject? ToJsonObjectSafe(RawJson request) { - if (request == null) - return null; - - if (request is JsonObject jsonObject) - return jsonObject; - - if (request is JsonElement jsonElement) - { - if (jsonElement.ValueKind == JsonValueKind.Object) - return JsonNode.Parse(jsonElement.GetRawText())?.AsObject(); + if (request.IsEmpty) return null; - } try { - string json = JsonSerializer.Serialize(request); - return JsonNode.Parse(json)?.AsObject(); + return JsonNode.Parse(request.Span) as JsonObject; } - catch + catch (JsonException) { return null; } diff --git a/Xrpl/Client/IXrplClient.cs b/Xrpl/Client/IXrplClient.cs index 76230b73..aa968dec 100644 --- a/Xrpl/Client/IXrplClient.cs +++ b/Xrpl/Client/IXrplClient.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; +using Xrpl.Client.Exceptions; using Xrpl.Client.Json; using Xrpl.Client.Json.Converters; using Xrpl.Models.Ledger; @@ -43,10 +44,194 @@ namespace Xrpl.Client public delegate Task OnBookChanges(BookChangesStream response); public delegate Task OnServerStatus(ServerStatusStream response); + /// + /// Why the connection session ended. + /// + public enum SessionEndReason + { + /// + /// The connection dropped, or the SDK tore it down itself to reconnect after a ping + /// timeout or a network drop. + /// + ConnectionLost, + + /// The caller moved to another server with ChangeServer. + ServerChanged, + + /// The caller closed the connection with Disconnect. + UserDisconnected, + } + + /// + /// A connection session has ended. Everything the node held against that connection - the + /// subscriptions above all - went with it. + /// + /// What ended the session. + /// Human-readable detail, for logs. + public delegate Task OnSessionEnded(SessionEndReason reason, string description); + public interface IXrplClient : IDisposable { - Connection connection { get; set; } + /// + /// The socket this client speaks over. + /// + /// + /// Read-only: replacing it would leave every handler registered through the events below + /// attached to the old object, and the stream would go quiet with nothing to show for it. + /// ChangeServer is how a caller moves to another server - it swaps the session + /// inside this object rather than the object itself, so subscriptions survive. + /// + Connection connection { get; } + + /// + /// How many stream messages were discarded because handlers fell behind. + /// + /// + /// Non-zero means events reached this client and never reached its handlers. The queue in + /// front of them is bounded () + /// and drops the oldest when full, so a slow handler costs events instead of stalling the + /// socket - silently, until something reads this. A consumer building state from the + /// stream should treat any increase as a signal that its state has drifted. + /// + /// Defaulted, like : forwarding to is + /// the only implementation that means anything, and an observational member is a poor + /// reason to break every external implementer of this interface. + /// + /// + long DroppedStreamMessages => connection.DroppedStreamMessages; + + /// + /// How many stream frames were discarded because they came from a connection this client + /// had already left. + /// + /// + /// A socket being retired keeps delivering until its graceful close finishes, so a few + /// frames can arrive after a reconnect or ChangeServer has moved on. Delivering them + /// would be wrong rather than merely late: after a change of network they describe a + /// different chain entirely. A non-zero value here is therefore normal right after a + /// reconnect and says nothing about handler speed - that is + /// , and the two are counted apart on purpose. + /// + /// Defaulted, like : forwarding to is + /// the only implementation that means anything, and an observational member is a poor + /// reason to break every external implementer of this interface. + /// + /// + long StaleSessionFramesDropped => connection.StaleSessionFramesDropped; + + /// + /// How many stream frames were dispatched outside the queue, without its ordering or its + /// capacity bound. + /// + /// + /// Frames take that path when the background processor is not up yet, when it has been + /// stopped, or when the channel refuses a write. The first of those is a real window on + /// every connect: the processor starts at the end of OnceOpen, after the + /// OnConnected callback, so a handler subscribing there can be reached before the + /// queue exists. This counts how often that happened. + /// + /// Defaulted, like : forwarding to is + /// the only implementation that means anything, and an observational member is a poor + /// reason to break every external implementer of this interface. + /// + /// + long FallbackDispatchedStreamMessages => connection.FallbackDispatchedStreamMessages; + + /// Node error reported over the socket. + event OnError OnError; + + /// Node warning reported over the socket. + event OnWarning OnWarning; + + /// Server warnings attached to a response envelope. + event OnServerWarning OnServerWarning; + + /// The socket finished connecting. + event OnConnected OnConnected; + + /// The socket closed. + event OnDisconnect OnDisconnect; + + /// + /// The connection that carried the subscriptions has ended - by any route. + /// + /// + /// + /// Subscriptions live on the node against one connection. When that connection goes, so do + /// they, and a consumer has to resubscribe. Knowing when to is the hard part, because the + /// connection can end in three different ways and until this event only the first was + /// announced: + /// + /// + /// the socket closes on its own, or the caller closes it with + /// Disconnect - fires; + /// a ping timeout or a network drop makes the SDK retire the session + /// and reconnect at once - only a + /// status went out; + /// ChangeServer retires the session to move to another server - + /// nothing went out at all, and the client reported Connected throughout, so a + /// consumer had no way to notice its stream had gone quiet for good. + /// + /// + /// This event fires exactly once per session in all three cases, which makes it the single + /// thing to subscribe to in order to know that a resubscribe is due. + /// keeps its own meaning - a socket closed - and still fires + /// alongside it where it always did. + /// + /// + /// It reports a loss, not a readiness: on a server switch and on a fast reconnect it fires + /// before the replacement connection is open, so the handler cannot resubscribe from where + /// it stands. Note that the subscriptions are gone and send them again from + /// - which is also why the loss is announced first, so a consumer + /// is never told to resubscribe after it has already seen the new connection come up. + /// + /// + /// Handlers are awaited before the SDK carries on, so a slow one holds up the very + /// reconnect or server switch it is reporting. Keep the work short. + /// + /// + /// It does not fire for a connection attempt that never succeeded: there was no session, + /// and so no subscription, to lose. It does fire when the client is disposed, since + /// closes the connection - and, because that close is not + /// awaited, possibly after Dispose has returned. A handler that touches the client + /// should be detached before disposing it. + /// + /// + event OnSessionEnded OnSessionEnded; + + /// Keep-alive round trip completed. + event OnPing OnPing; + + /// ledgerClosed stream event. + event OnLedgerClosed OnLedgerClosed; + + /// transaction stream event - the one a wallet renders for signing. + event OnTransaction OnTransaction; + + /// validationReceived stream event. + event OnValidationReceived OnValidationReceived; + + /// manifestReceived stream event. + event OnManifestReceived OnManifestReceived; + + /// peerStatusChange stream event. + event OnPeerStatusChange OnPeerStatusChange; + + /// consensusPhase stream event. + event OnConsensusPhase OnConsensusPhase; + + /// path_find follow-up. + event OnPathFind OnPathFind; + + /// bookChanges stream event. + event OnBookChanges OnBookChanges; + + /// serverStatus stream event. + event OnServerStatus OnServerStatus; + + /// Connection state transitions, for diagnostics. + event Action OnConnectionStatus; double feeCushion { get; set; } string maxFeeXRP { get; set; } uint? networkID { get; set; } @@ -63,7 +248,24 @@ public void SetNetworkId(uint? networkId) #region Server /// the url string Url(); - /// connect to the server + /// + /// Connects to the server and reads the network id from it. + /// + /// + /// + /// Returns when the client is connected and that second step has completed. A consumer + /// handler that fails and works on the next attempt does not + /// reach the caller: the client rebuilds the connection itself, and reporting that as a + /// failed connect would be reporting something that did not happen. + /// + /// + /// Throws when the client gave up - a + /// handler that fails every time, or a server that never comes up. + /// means what it says and nothing else: the + /// caller's own was cancelled. + /// + /// + /// Cancels the wait for a connection. Task Connect(System.Threading.CancellationToken cancellationToken = default); /// Disconnect from server Task Disconnect(); @@ -78,26 +280,25 @@ public void SetNetworkId(uint? networkId) /// The subscribe method requests periodic notifications from the server when certain events happen. /// An request. /// - Task Subscribe(SubscribeRequest request, CancellationToken cancellationToken = default); + Task> Subscribe(SubscribeRequest request, CancellationToken cancellationToken = default); /// The unsubscribe command tells the server to stop sending messages for a particular subscription or set of subscriptions. /// An request. /// - Task Unsubscribe(UnsubscribeRequest request, CancellationToken cancellationToken = default); + Task> Unsubscribe(UnsubscribeRequest request, CancellationToken cancellationToken = default); /// /// The ping command returns an acknowledgement, /// so that clients can test the connection status and latency /// - /// An request. /// - Task Ping(CancellationToken cancellationToken = default); + Task> Ping(CancellationToken cancellationToken = default); /// The server_info command asks the server for a human-readable version of various information about the rippled server being queried. /// An request. /// A response. - Task ServerInfo(ServerInfoRequest request, CancellationToken cancellationToken = default); + Task> ServerInfo(ServerInfoRequest request, CancellationToken cancellationToken = default); /// The server_state command asks the server for a human-readable version of various information about the rippled server being queried. /// An request. /// A response. - Task ServerState(ServerStateRequest request, CancellationToken cancellationToken = default); + Task> ServerState(ServerStateRequest request, CancellationToken cancellationToken = default); /// /// The feature command returns information about amendments this server knows about,
@@ -112,12 +313,11 @@ public void SetNetworkId(uint? networkId) /// If provided, limits the response to one amendment. Otherwise, the response lists all amendments. /// /// A response. Feature and their states - Task ServerFeatures(string feature = null, CancellationToken cancellationToken = default); + Task> ServerFeatures(string feature = null, CancellationToken cancellationToken = default); /// The fee command reports the current state of the open-ledger requirements for the transaction cost. - /// An request. /// An response. - Task Fee(CancellationToken cancellationToken = default); + Task> Fee(CancellationToken cancellationToken = default); /// /// The server_definitions method retrieves the definition enums used by the server. @@ -125,7 +325,7 @@ public void SetNetworkId(uint? networkId) /// A . /// Cancellation token. /// A . - Task ServerDefinitions(ServerDefinitionsRequest request, CancellationToken cancellationToken = default); + Task> ServerDefinitions(ServerDefinitionsRequest request, CancellationToken cancellationToken = default); /// /// The vault_info method retrieves information about a vault. @@ -133,7 +333,7 @@ public void SetNetworkId(uint? networkId) /// A . /// Cancellation token. /// A . - Task VaultInfo(VaultInfoRequest request, CancellationToken cancellationToken = default); + Task> VaultInfo(VaultInfoRequest request, CancellationToken cancellationToken = default); #endregion @@ -142,18 +342,18 @@ public void SetNetworkId(uint? networkId) /// The account_info command retrieves information about an account, its activity, and its XRP balance. /// An request. /// An response. - Task AccountInfo(AccountInfoRequest request, CancellationToken cancellationToken = default); + Task> AccountInfo(AccountInfoRequest request, CancellationToken cancellationToken = default); /// The account_offers method retrieves a list of offers made by a given account that are outstanding as of a particular ledger version /// An request. /// An response. - Task AccountOffers(AccountOffersRequest request, CancellationToken cancellationToken = default); + Task> AccountOffers(AccountOffersRequest request, CancellationToken cancellationToken = default); /// The account_currencies command retrieves a list of currencies that an account can send or receive, based on its trust lines. /// An request. /// An response. - Task AccountCurrencies(AccountCurrenciesRequest request, CancellationToken cancellationToken = default); + Task> AccountCurrencies(AccountCurrenciesRequest request, CancellationToken cancellationToken = default); /// @@ -161,7 +361,7 @@ public void SetNetworkId(uint? networkId) /// /// An request. /// An response. - Task AccountLines(AccountLinesRequest request, CancellationToken cancellationToken = default); + Task> AccountLines(AccountLinesRequest request, CancellationToken cancellationToken = default); /// @@ -169,7 +369,7 @@ public void SetNetworkId(uint? networkId) /// /// An request. /// An response. - Task AccountObjects(AccountObjectsRequest request, CancellationToken cancellationToken = default); + Task> AccountObjects(AccountObjectsRequest request, CancellationToken cancellationToken = default); /// @@ -178,25 +378,25 @@ public void SetNetworkId(uint? networkId) /// /// An response. /// An response. - Task NoRippleCheck(NoRippleCheckRequest request, CancellationToken cancellationToken = default); + Task> NoRippleCheck(NoRippleCheckRequest request, CancellationToken cancellationToken = default); /// The gateway_balances command calculates the total balances issued by a given account, /// optionally excluding amounts held by operational addresses. /// An request. /// An response. - Task GatewayBalances(GatewayBalancesRequest request, CancellationToken cancellationToken = default); + Task> GatewayBalances(GatewayBalancesRequest request, CancellationToken cancellationToken = default); /// The account_tx method retrieves a list of transactions that involved the specified account /// An request. /// An response. - Task AccountTransactions(AccountTransactionsRequest request, CancellationToken cancellationToken = default); + Task> AccountTransactions(AccountTransactionsRequest request, CancellationToken cancellationToken = default); /// The account_channels method returns information about an account's Payment Channels. /// This includes only channels where the specified account is the channel's source, not the destination. /// An request. /// An response. - Task AccountChannels(AccountChannelsRequest request, CancellationToken cancellationToken = default); + Task> AccountChannels(AccountChannelsRequest request, CancellationToken cancellationToken = default); /// /// The simulate method executes a dry run of any transaction type, @@ -206,7 +406,7 @@ public void SetNetworkId(uint? networkId) /// /// /// - Task Simulate(SimulateRequest request, CancellationToken cancellationToken = default); + Task> Simulate(SimulateRequest request, CancellationToken cancellationToken = default); #endregion #region NFT @@ -215,18 +415,42 @@ public void SetNetworkId(uint? networkId) /// The nft_buy_offers method returns a list of buy offers for a given NFToken object. /// An request. /// An response. - Task NFTBuyOffers(NFTBuyOffersRequest request, CancellationToken cancellationToken = default); + Task> NFTBuyOffers(NFTBuyOffersRequest request, CancellationToken cancellationToken = default); /// The nft_sell_offers method returns a list of sell offers for a given NFToken object /// An request. /// An response. - Task NFTSellOffers(NFTSellOffersRequest request, CancellationToken cancellationToken = default); + Task> NFTSellOffers(NFTSellOffersRequest request, CancellationToken cancellationToken = default); + + /// + /// The nft_info method says who owns an NFToken and what it was minted with. + /// + /// + /// A Clio method: a plain rippled node answers unknownCmd, which arrives as an + /// ordinary node error so a caller can catch it and fall back. There is no substitute for + /// it on rippled - an owner cannot be read out of , because a + /// sale leaves the seller's offers in the ledger and the new owner usually has none. + /// + /// An request. + /// An response. + Task> NFTInfo(NFTInfoRequest request, CancellationToken cancellationToken = default); + + /// + /// The nft_history method returns the transactions that touched an NFToken. + /// + /// + /// A Clio method, paginated the way account_tx is: keep passing + /// back until an answer comes without one. + /// + /// An request. + /// An response. + Task> NFTHistory(NFTHistoryRequest request, CancellationToken cancellationToken = default); /// The account_nfts method returns a list of NFToken objects for the specified account. /// An request. /// An response. - Task AccountNFTs(AccountNFTsRequest request, CancellationToken cancellationToken = default); + Task> AccountNFTs(AccountNFTsRequest request, CancellationToken cancellationToken = default); #endregion @@ -264,13 +488,33 @@ public void SetNetworkId(uint? networkId) /// An response. Task Submit(ITransactionRequest tx, XrplWallet wallet, bool autoFill = true, bool failHard = false, CancellationToken cancellationToken = default); /// - /// The tx method retrieves information on a single transaction, by its identifying hash + /// The tx method retrieves information on a single transaction, by its identifying hash, using + /// the API v1 wire shape: the transaction's own fields (Account, Amount, TransactionType, ...) + /// sit at the top level of , alongside meta. /// + /// + /// Always requests API v1 regardless of — this is the one + /// place in the SDK where the choice of method, not a setting, decides the protocol version. It + /// cannot honor instead: + /// has no field for API v2's tx_json, so handing it a v2 payload would lose the transaction + /// wholesale rather than just its field names. Use for the v2 shape. + /// /// An request. /// An response. - Task Tx(TxRequest request, CancellationToken cancellationToken = default); + Task> TxV1(TxRequest request, CancellationToken cancellationToken = default); - Task TxV2(TxRequest request, CancellationToken cancellationToken = default); + /// + /// The tx method retrieves information on a single transaction, by its identifying hash, using + /// the API v2 wire shape: (from tx_json) and + /// sit side by side, as rippled's v2 response actually sends + /// them. + /// + /// + /// Always requests API v2 regardless of — like + /// , the choice of method decides the protocol version here, not the client + /// setting. Use for the v1 shape. + /// + Task> TxV2(TxRequest request, CancellationToken cancellationToken = default); /// /// The transaction_entry method retrieves information on a single transaction @@ -279,7 +523,7 @@ public void SetNetworkId(uint? networkId) /// A . /// Cancellation token. /// A . - Task TransactionEntry(TransactionEntryRequest request, CancellationToken cancellationToken = default); + Task> TransactionEntry(TransactionEntryRequest request, CancellationToken cancellationToken = default); #endregion #region Channels @@ -291,7 +535,7 @@ public void SetNetworkId(uint? networkId) /// A . /// Cancellation token. /// A containing the signature. - Task ChannelAuthorize(ChannelAuthorizeRequest request, CancellationToken cancellationToken = default); + Task> ChannelAuthorize(ChannelAuthorizeRequest request, CancellationToken cancellationToken = default); /// /// The channel_verify method checks the validity of a signature that can be @@ -300,7 +544,7 @@ public void SetNetworkId(uint? networkId) /// A . /// Cancellation token. /// A indicating whether the signature is valid. - Task ChannelVerify(ChannelVerifyRequest request, CancellationToken cancellationToken = default); + Task> ChannelVerify(ChannelVerifyRequest request, CancellationToken cancellationToken = default); #endregion @@ -314,7 +558,7 @@ public void SetNetworkId(uint? networkId) /// /// An request. /// An response. - Task Ledger(LedgerRequest request, CancellationToken cancellationToken = default); + Task> Ledger(LedgerRequest request, CancellationToken cancellationToken = default); /// /// The ledger_data method retrieves contents of the specified ledger. @@ -322,25 +566,25 @@ public void SetNetworkId(uint? networkId) /// /// An request. /// An response. - Task LedgerData(LedgerDataRequest request, CancellationToken cancellationToken = default); + Task> LedgerData(LedgerDataRequest request, CancellationToken cancellationToken = default); /// The ledger_closed method returns the unique identifiers of the most recently closed ledger. /// An response. /// An response. - Task LedgerClosed(LedgerClosedRequest request, CancellationToken cancellationToken = default); + Task> LedgerClosed(LedgerClosedRequest request, CancellationToken cancellationToken = default); /// /// The ledger_current method returns the unique identifiers of the current in-progress ledger.
/// This command is mostly useful for testing, because the ledger returned is still in flux. ///
/// An response. /// An response. - Task LedgerCurrent(LedgerCurrentRequest request, CancellationToken cancellationToken = default); + Task> LedgerCurrent(LedgerCurrentRequest request, CancellationToken cancellationToken = default); /// /// The ledger_entry method returns a single ledger object from the XRP Ledger in its raw format.
/// See ledger format for information on the different types of objects you can retrieve. ///
/// An response. /// An response. - Task LedgerEntry(LedgerEntryRequest request, CancellationToken cancellationToken = default); + Task> LedgerEntry(LedgerEntryRequest request, CancellationToken cancellationToken = default); #endregion @@ -350,20 +594,19 @@ public void SetNetworkId(uint? networkId) ///
/// An request. /// An response. - Task AmmInfo(AMMInfoRequest request, CancellationToken cancellationToken = default); + Task> AmmInfo(AMMInfoRequest request, CancellationToken cancellationToken = default); /// /// The book_offers method retrieves a list of offers, also known as the order book , between two currencies /// /// An request. /// An response. - Task BookOffers(BookOffersRequest request, CancellationToken cancellationToken = default); + Task> BookOffers(BookOffersRequest request, CancellationToken cancellationToken = default); /// /// The random command provides a random number to be used as a source of entropy for random number generation by clients.
/// https://xrpl.org/random.html#random ///
- /// An request. /// - Task Random(CancellationToken cancellationToken = default); + Task> Random(CancellationToken cancellationToken = default); /// /// The deposit_authorized command indicates whether one account is authorized to send payments @@ -372,7 +615,7 @@ public void SetNetworkId(uint? networkId) /// A . /// Cancellation token. /// A response. - Task DepositAuthorized(DepositAuthorizedRequest request, CancellationToken cancellationToken = default); + Task> DepositAuthorized(DepositAuthorizedRequest request, CancellationToken cancellationToken = default); /// /// The path_find create sub-command creates an ongoing request to find possible paths @@ -383,7 +626,7 @@ public void SetNetworkId(uint? networkId) /// A . /// Cancellation token. /// A with initial path alternatives. - Task PathFind(PathFindCreateRequest request, CancellationToken cancellationToken = default); + Task> PathFind(PathFindCreateRequest request, CancellationToken cancellationToken = default); /// /// The path_find close sub-command instructs the server to stop sending information @@ -392,7 +635,7 @@ public void SetNetworkId(uint? networkId) /// A . /// Cancellation token. /// A . - Task PathFindClose(PathFindCloseRequest request, CancellationToken cancellationToken = default); + Task> PathFindClose(PathFindCloseRequest request, CancellationToken cancellationToken = default); /// /// The path_find status sub-command requests an immediate update about the client's @@ -401,7 +644,7 @@ public void SetNetworkId(uint? networkId) /// A . /// Cancellation token. /// A . - Task PathFindStatus(PathFindStatusRequest request, CancellationToken cancellationToken = default); + Task> PathFindStatus(PathFindStatusRequest request, CancellationToken cancellationToken = default); /// /// The ripple_path_find method is a simplified version of the path_find method @@ -411,12 +654,12 @@ public void SetNetworkId(uint? networkId) /// A . /// Cancellation token. /// A . - Task RipplePathFind(RipplePathFindRequest request, CancellationToken cancellationToken = default); + Task> RipplePathFind(RipplePathFindRequest request, CancellationToken cancellationToken = default); - Task AnyRequest(BaseRequest request, CancellationToken cancellationToken = default); + Task> AnyRequest(BaseRequest request, CancellationToken cancellationToken = default); - Task> Request(Dictionary request, CancellationToken cancellationToken = default); - Task GRequest(R request, CancellationToken cancellationToken = default) where R : BaseRequest; + Task>> Request(Dictionary request, CancellationToken cancellationToken = default); + Task> GRequest(R request, CancellationToken cancellationToken = default) where R : BaseRequest; #region Sugars @@ -464,7 +707,133 @@ public class ClientOptions : ConnectionOptions public uint? ApiVersion { get; set; } } - public Connection connection { get; set; } + // get-only, not `private set`: the one-assignment invariant the forwarding below depends on + // is then checked by the compiler rather than by everyone who edits this 1100-line class. + // A second assignment would strand every handler attached through these events on the old + // object. + public Connection connection { get; } + + /// + public long DroppedStreamMessages => connection.DroppedStreamMessages; + + /// + public long StaleSessionFramesDropped => connection.StaleSessionFramesDropped; + + /// + public long FallbackDispatchedStreamMessages => connection.FallbackDispatchedStreamMessages; + + // Forwarded, not relayed: add/remove reach the same Connection a caller would have used + // through the property, so this type holds no delegates and no subscription of its own. A + // relaying version - a local event plus a subscription to the connection that re-raises it + // - would add a second subscriber list, a subscription nothing removes, and a second place + // to keep in sync. There is nothing to keep in sync here. + // + // Safe because the Connection outlives the client: it is assigned once, in the + // constructor, and ChangeServer swaps the session inside it rather than the object. Were + // that to change, handlers would silently stay on the old object - which is why the + // property lost its public setter. + + public event OnError OnError + { + add => connection.OnError += value; + remove => connection.OnError -= value; + } + + public event OnWarning OnWarning + { + add => connection.OnWarning += value; + remove => connection.OnWarning -= value; + } + + public event OnServerWarning OnServerWarning + { + add => connection.OnServerWarning += value; + remove => connection.OnServerWarning -= value; + } + + public event OnConnected OnConnected + { + add => connection.OnConnected += value; + remove => connection.OnConnected -= value; + } + + public event OnDisconnect OnDisconnect + { + add => connection.OnDisconnect += value; + remove => connection.OnDisconnect -= value; + } + + public event OnSessionEnded OnSessionEnded + { + add => connection.OnSessionEnded += value; + remove => connection.OnSessionEnded -= value; + } + + public event OnPing OnPing + { + add => connection.OnPing += value; + remove => connection.OnPing -= value; + } + + public event OnLedgerClosed OnLedgerClosed + { + add => connection.OnLedgerClosed += value; + remove => connection.OnLedgerClosed -= value; + } + + public event OnTransaction OnTransaction + { + add => connection.OnTransaction += value; + remove => connection.OnTransaction -= value; + } + + public event OnValidationReceived OnValidationReceived + { + add => connection.OnValidationReceived += value; + remove => connection.OnValidationReceived -= value; + } + + public event OnManifestReceived OnManifestReceived + { + add => connection.OnManifestReceived += value; + remove => connection.OnManifestReceived -= value; + } + + public event OnPeerStatusChange OnPeerStatusChange + { + add => connection.OnPeerStatusChange += value; + remove => connection.OnPeerStatusChange -= value; + } + + public event OnConsensusPhase OnConsensusPhase + { + add => connection.OnConsensusPhase += value; + remove => connection.OnConsensusPhase -= value; + } + + public event OnPathFind OnPathFind + { + add => connection.OnPathFind += value; + remove => connection.OnPathFind -= value; + } + + public event OnBookChanges OnBookChanges + { + add => connection.OnBookChanges += value; + remove => connection.OnBookChanges -= value; + } + + public event OnServerStatus OnServerStatus + { + add => connection.OnServerStatus += value; + remove => connection.OnServerStatus -= value; + } + + public event Action OnConnectionStatus + { + add => connection.OnConnectionStatus += value; + remove => connection.OnConnectionStatus -= value; + } public double feeCushion { get; set; } public string maxFeeXRP { get; set; } public uint? networkID { get; set; } @@ -480,15 +849,6 @@ public class ClientOptions : ConnectionOptions /// The API version to use when making requests. /// public uint ApiVersion { get; set; } - //public event OnError OnError; - //public event OnConnected OnConnected; - //public event OnDisconnect OnDisconnect; - //public event OnLedgerClosed OnLedgerClosed; - //public event OnTransaction OnTransaction; - //public event OnManifestReceived OnManifestReceived; - //public event OnPeerStatusChange OnPeerStatusChange; - //public event OnConsensusPhase OnConsensusPhase; - //public event OnPathFind OnPathFind; ///// Current web socket client state //public WebSocketState SocketState => client.State; @@ -540,21 +900,66 @@ public bool IsValidWss(string server) return true; } - /// - /// Connect to the server - /// - /// cancellation token - /// + /// public async Task Connect(System.Threading.CancellationToken cancellationToken = default) { await connection.Connect(cancellationToken); - await SetNetworkId(); + await SetNetworkIdWhileConnectingAsync(cancellationToken); + } + + /// + /// Reads the network id, surviving a connection the client is still settling. + /// + /// + /// + /// Connect() is two operations, not one: the connection, and this. The socket really + /// does open for a moment before a failing OnConnected handler brings it down, so the + /// wait inside the first operation can return successfully while the second is still in + /// flight - and the teardown then rejects it. Letting that reach the caller was wrong twice + /// over: they cancelled nothing, and a handler that fails once and works on the next attempt + /// is precisely what the reconnect path is for, so moments later the client was connected + /// and Connect() had reported a failure that did not happen. + /// + /// + /// Waiting rather than asking whether the client is connected right now: at the moment the + /// request is rejected the socket has just been torn down, so the answer would be no even + /// though the recovery is already under way. The wait ends when the connection is back, or + /// throws if the client gave up - the one case where the + /// caller really did fail to connect. + /// + /// + /// More than one attempt because the wait can also return during the teardown itself, while + /// the old socket is still nominally open; the request then goes out on a socket that is + /// already dying. The cap is the number of times the connection may be rebuilt: each retry + /// here answers one teardown, and the client cannot tear down more often than that before it + /// gives up. + /// + /// + private async Task SetNetworkIdWhileConnectingAsync(CancellationToken cancellationToken) + { + int attempts = Math.Max(1, connection.config.MaxReconnectAttempts); + + for (int attempt = 1; ; attempt++) + { + try + { + await SetNetworkId(); + return; + } + catch (Exception error) when ( + error is OperationCanceledException or DisconnectedException && + !cancellationToken.IsCancellationRequested && + attempt < attempts) + { + await connection.WaitForConnectionAsync(cancellationToken: cancellationToken); + } + } } private async Task SetNetworkId() { var server = await ServerInfo(new ServerInfoRequest()); - if (server?.Info?.NetworkID is { } id and > 1024) + if (server.Result?.Info?.NetworkID is { } id and > 1024) { SetNetworkId(id); } @@ -639,199 +1044,211 @@ public Task GetXrpBalance(string address, CancellationToken cancellation // REQUESTS /// - public Task AccountChannels(AccountChannelsRequest request, CancellationToken cancellationToken = default) + public Task> AccountChannels(AccountChannelsRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task ChannelAuthorize(ChannelAuthorizeRequest request, CancellationToken cancellationToken = default) + public Task> ChannelAuthorize(ChannelAuthorizeRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task ChannelVerify(ChannelVerifyRequest request, CancellationToken cancellationToken = default) + public Task> ChannelVerify(ChannelVerifyRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } - public Task Simulate(SimulateRequest request, CancellationToken cancellationToken = default) + public Task> Simulate(SimulateRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task AccountCurrencies(AccountCurrenciesRequest request, CancellationToken cancellationToken = default) + public Task> AccountCurrencies(AccountCurrenciesRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task AccountInfo(AccountInfoRequest request, CancellationToken cancellationToken = default) + public Task> AccountInfo(AccountInfoRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task AccountLines(AccountLinesRequest request, CancellationToken cancellationToken = default) + public Task> AccountLines(AccountLinesRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task AccountNFTs(AccountNFTsRequest request, CancellationToken cancellationToken = default) + public Task> AccountNFTs(AccountNFTsRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task AccountObjects(AccountObjectsRequest request, CancellationToken cancellationToken = default) + public Task> AccountObjects(AccountObjectsRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task AccountOffers(AccountOffersRequest request, CancellationToken cancellationToken = default) + public Task> AccountOffers(AccountOffersRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task AccountTransactions(AccountTransactionsRequest request, CancellationToken cancellationToken = default) + public Task> AccountTransactions(AccountTransactionsRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task AmmInfo(AMMInfoRequest request, CancellationToken cancellationToken = default) + public Task> AmmInfo(AMMInfoRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task BookOffers(BookOffersRequest request, CancellationToken cancellationToken = default) + public Task> BookOffers(BookOffersRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task DepositAuthorized(DepositAuthorizedRequest request, CancellationToken cancellationToken = default) + public Task> DepositAuthorized(DepositAuthorizedRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task Ledger(LedgerRequest request, CancellationToken cancellationToken = default) + public Task> Ledger(LedgerRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task LedgerClosed(LedgerClosedRequest request, CancellationToken cancellationToken = default) + public Task> LedgerClosed(LedgerClosedRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task LedgerCurrent(LedgerCurrentRequest request, CancellationToken cancellationToken = default) + public Task> LedgerCurrent(LedgerCurrentRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task LedgerData(LedgerDataRequest request, CancellationToken cancellationToken = default) + public Task> LedgerData(LedgerDataRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task LedgerEntry(LedgerEntryRequest request, CancellationToken cancellationToken = default) + public Task> LedgerEntry(LedgerEntryRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task Fee(CancellationToken cancellationToken = default) + public Task> Fee(CancellationToken cancellationToken = default) { FeeRequest request = new FeeRequest(); return this.GRequest(request, cancellationToken); } /// - public Task GatewayBalances(GatewayBalancesRequest request, CancellationToken cancellationToken = default) + public Task> GatewayBalances(GatewayBalancesRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task NFTBuyOffers(NFTBuyOffersRequest request, CancellationToken cancellationToken = default) + public Task> NFTBuyOffers(NFTBuyOffersRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task NFTSellOffers(NFTSellOffersRequest request, CancellationToken cancellationToken = default) + public Task> NFTSellOffers(NFTSellOffersRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task NoRippleCheck(NoRippleCheckRequest request, CancellationToken cancellationToken = default) + public Task> NFTInfo(NFTInfoRequest request, CancellationToken cancellationToken = default) + { + return this.GRequest(request, cancellationToken); + } + + /// + public Task> NFTHistory(NFTHistoryRequest request, CancellationToken cancellationToken = default) + { + return this.GRequest(request, cancellationToken); + } + + /// + public Task> NoRippleCheck(NoRippleCheckRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task PathFind(PathFindCreateRequest request, CancellationToken cancellationToken = default) + public Task> PathFind(PathFindCreateRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task PathFindClose(PathFindCloseRequest request, CancellationToken cancellationToken = default) + public Task> PathFindClose(PathFindCloseRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task PathFindStatus(PathFindStatusRequest request, CancellationToken cancellationToken = default) + public Task> PathFindStatus(PathFindStatusRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task Ping(CancellationToken cancellationToken = default) + public Task> Ping(CancellationToken cancellationToken = default) { PingRequest request = new PingRequest(); return this.GRequest(request, cancellationToken); } /// - public Task Random(CancellationToken cancellationToken = default) + public Task> Random(CancellationToken cancellationToken = default) { RandomRequest request = new RandomRequest(); return this.GRequest(request, cancellationToken); } /// - public Task RipplePathFind(RipplePathFindRequest request, CancellationToken cancellationToken = default) + public Task> RipplePathFind(RipplePathFindRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task ServerInfo(ServerInfoRequest request, CancellationToken cancellationToken = default) + public Task> ServerInfo(ServerInfoRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task ServerState(ServerStateRequest request, CancellationToken cancellationToken = default) + public Task> ServerState(ServerStateRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task ServerFeatures(string feature = null, CancellationToken cancellationToken = default) + public Task> ServerFeatures(string feature = null, CancellationToken cancellationToken = default) { var request = new ServerFeaturesRequest() { @@ -841,18 +1258,17 @@ public Task ServerFeatures(string feature = null, CancellationTo } /// - public Task ServerDefinitions(ServerDefinitionsRequest request, CancellationToken cancellationToken = default) + public Task> ServerDefinitions(ServerDefinitionsRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task VaultInfo(VaultInfoRequest request, CancellationToken cancellationToken = default) + public Task> VaultInfo(VaultInfoRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } - /// //public Task Submit(SubmitRequest request) //{ // return this.GRequest(request); @@ -864,46 +1280,47 @@ public Task VaultInfo(VaultInfoRequest request, CancellationT //} /// - public Task Subscribe(SubscribeRequest request, CancellationToken cancellationToken = default) + public Task> Subscribe(SubscribeRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task Unsubscribe(UnsubscribeRequest request, CancellationToken cancellationToken = default) + public Task> Unsubscribe(UnsubscribeRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task TransactionEntry(TransactionEntryRequest request, CancellationToken cancellationToken = default) + public Task> TransactionEntry(TransactionEntryRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public Task Tx(TxRequest request, CancellationToken cancellationToken = default) + public Task> TxV1(TxRequest request, CancellationToken cancellationToken = default) { request.ApiVersion = 1; return this.GRequest(request, cancellationToken); } - public Task TxV2(TxRequest request, CancellationToken cancellationToken = default) + /// + public Task> TxV2(TxRequest request, CancellationToken cancellationToken = default) { request.ApiVersion = 2; return this.GRequest(request, cancellationToken); } /// - public Task AnyRequest(BaseRequest request, CancellationToken cancellationToken = default) + public Task> AnyRequest(BaseRequest request, CancellationToken cancellationToken = default) { return this.GRequest(request, cancellationToken); } /// - public async Task> Request(Dictionary request, CancellationToken cancellationToken = default) + public Task>> Request(Dictionary request, CancellationToken cancellationToken = default) { //string account = request["Account"] ? EnsureClassicAddress((string)request["account"]) : null; //request["Account"] = account; @@ -918,24 +1335,14 @@ public async Task> Request(Dictionary request[ApiVersionField] = ApiVersion; } - var response = await this.connection.Request(request, cancellationToken: cancellationToken); - - // mutates `response` to add warnings - //handlePartialPayment(req.command, response) - return response; - + return this.connection.Request(request, cancellationToken: cancellationToken); } /// - public async Task GRequest(R request, CancellationToken cancellationToken = default) where R : BaseRequest + public Task> GRequest(R request, CancellationToken cancellationToken = default) where R : BaseRequest { request.ApiVersion ??= ApiVersion; - object response = await this.connection.GRequest(request, cancellationToken: cancellationToken); - - // mutates `response` to add warnings - //handlePartialPayment(req.command, response) - - return (T)response; + return this.connection.GRequest(request, cancellationToken: cancellationToken); } public string EnsureClassicAddress(string address) diff --git a/Xrpl/Client/Json/Converters/FromStringDateTimeConverter.cs b/Xrpl/Client/Json/Converters/FromStringDateTimeConverter.cs index a8fe64bc..7584eda6 100644 --- a/Xrpl/Client/Json/Converters/FromStringDateTimeConverter.cs +++ b/Xrpl/Client/Json/Converters/FromStringDateTimeConverter.cs @@ -19,8 +19,19 @@ public class FromStringDateTimeConverter : JsonConverter case JsonTokenType.String: { string dateTimeString = reader.GetString(); - // Попробуем разобрать строку в DateTime - if (DateTime.TryParseExact(dateTimeString, "yyyy-MM-ddTHH:mm:sszzz", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out DateTime dateTime)) + // "K" accepts both the "Z" suffix rippled actually sends for close_time_iso + // (e.g. "2013-03-12T23:16:50Z") and a numeric offset ("+02:00"); "zzz" alone + // only accepts the latter, so every real "Z"-suffixed timestamp silently failed + // to parse and this converter returned null for it. AdjustToUniversal converts + // a numeric-offset value to UTC instead of leaving it in local time; AssumeUniversal + // still covers the (protocol-invalid) case of no zone marker at all, unchanged + // from before. + if (DateTime.TryParseExact( + dateTimeString, + "yyyy-MM-ddTHH:mm:ssK", + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out DateTime dateTime)) { return dateTime; } @@ -51,8 +62,21 @@ public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerialize { if (value is DateTime dateTime) { - // Записываем в формате ISO 8601 - writer.WriteStringValue(dateTime.ToString("yyyy-MM-ddTHH:mm:sszzz", CultureInfo.InvariantCulture)); + // "K" emits nothing at all for DateTimeKind.Unspecified - it is neither "Z" nor a + // numeric offset, so the value would round-trip with no zone marker whatsoever. Read + // only ever produces Utc, but a caller can assign a DateTime by hand, so Kind must be + // normalized before formatting: Unspecified is treated as UTC (mirrors + // DateTimeStyles.AssumeUniversal on the Read side), and Local is converted to UTC + // rather than emitting a local offset that Read would then normalize away, making the + // written value differ from the one an unchanged round trip would produce. + DateTime normalized = dateTime.Kind switch + { + DateTimeKind.Unspecified => DateTime.SpecifyKind(dateTime, DateTimeKind.Utc), + DateTimeKind.Local => dateTime.ToUniversalTime(), + _ => dateTime + }; + + writer.WriteStringValue(normalized.ToString("yyyy-MM-ddTHH:mm:ssK", CultureInfo.InvariantCulture)); } else { diff --git a/Xrpl/Client/Json/Converters/JsonSliceConverter.cs b/Xrpl/Client/Json/Converters/JsonSliceConverter.cs new file mode 100644 index 00000000..aee8d224 --- /dev/null +++ b/Xrpl/Client/Json/Converters/JsonSliceConverter.cs @@ -0,0 +1,54 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Xrpl.Client.Json.Converters +{ + /// + /// Records where a member sits in the frame instead of materializing it. + /// + /// + /// + /// Deserializing result into made System.Text.Json build a + /// self-contained for it, and JsonDocument.ParseValue rents + /// the backing array from without ever returning it — + /// 65 536 bytes for a 36 691-byte response, held for a subtree that was then parsed a second + /// time to reach the requested type. Skipping the subtree and remembering its bounds costs + /// nothing and leaves the single parse to the caller, straight out of the frame. + /// + /// + /// Contract: the reader must cover the whole document, and the consumer must hold the very + /// buffer it was created over. Offsets are relative to the start of that buffer, not to the + /// array behind it — a reader created over array.AsSpan(start, length) yields offsets + /// relative to start. + /// + /// + /// The Stream-based overloads are not supported: there System.Text.Json parses in chunks and + /// hands the converter a reader over its own read buffer, so the bounds come out relative to + /// that buffer — wrong, and with no exception to say so. The socket path hands the whole frame + /// over as one contiguous span, which is what makes the bounds meaningful. + /// + /// + internal sealed class JsonSliceConverter : JsonConverter + { + /// + public override JsonSlice Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + long start = reader.TokenStartIndex; + reader.Skip(); + long end = reader.BytesConsumed; + return new JsonSlice(checked((int)start), checked((int)(end - start))); + } + + /// + /// Always throws. A response envelope describes what a node sent; re-emitting it from the + /// parsed form would produce a plausible but different document, which is the failure mode + /// this type exists to remove. + /// + public override void Write(Utf8JsonWriter writer, JsonSlice value, JsonSerializerOptions options) + { + throw new NotSupportedException( + "A response envelope is not serializable: write the original bytes through RawJson instead."); + } + } +} diff --git a/Xrpl/Client/Json/Converters/LedgerObjectConverter.cs b/Xrpl/Client/Json/Converters/LedgerObjectConverter.cs index cd870d1c..c8cd28f5 100644 --- a/Xrpl/Client/Json/Converters/LedgerObjectConverter.cs +++ b/Xrpl/Client/Json/Converters/LedgerObjectConverter.cs @@ -237,7 +237,13 @@ public static BaseLedgerEntry GetBaseRippleLO( if (result != null) { - result.LedgerEntryType = type; + // `type` (from the owning node) only selects which C# class to deserialize into, via + // GetTypeForLedgerEntry above. It must not be stamped onto the result: rippled's wire + // format never repeats "LedgerEntryType" inside FinalFields/PreviousFields/NewFields + // (only the owning ModifiedNode/CreatedNode/DeletedNode carries it), so the field would + // otherwise be a fabricated non-null value whenever it is genuinely absent from `element`. + // The normal Deserialize call above already populates it correctly when the raw JSON does + // contain the member. if (string.IsNullOrWhiteSpace(result.LedgerIndex) && element.Value.TryGetProperty("LedgerIndex", out JsonElement liEl)) { diff --git a/Xrpl/Client/Json/Converters/ServerFeaturesConverter.cs b/Xrpl/Client/Json/Converters/ServerFeaturesConverter.cs index f8defd73..193fdf53 100644 --- a/Xrpl/Client/Json/Converters/ServerFeaturesConverter.cs +++ b/Xrpl/Client/Json/Converters/ServerFeaturesConverter.cs @@ -19,6 +19,26 @@ public sealed class ServerFeaturesConverter : JsonConverter "features" }; + /// + /// Reads a member as , treating anything it cannot be as absent. + /// + /// + /// ValueKind == Number is not enough: still raises + /// for a number outside the range - a negative + /// ledger_index, or one past ulong.MaxValue. TryGetUInt64 answers false + /// for those instead, so a malformed value degrades to "not sent" rather than failing the + /// whole response. + /// + private static ulong? UInt64OrNull(JsonElement root, string name) => + root.TryGetProperty(name, out JsonElement element) + && element.ValueKind == JsonValueKind.Number + && element.TryGetUInt64(out ulong value) + ? value + : (ulong?)null; + + private static string Text(JsonElement root, string name) => + root.TryGetProperty(name, out JsonElement e) && e.ValueKind == JsonValueKind.String ? e.GetString() : null; + public override ServerFeatures Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { using JsonDocument doc = JsonDocument.ParseValue(ref reader); @@ -26,9 +46,15 @@ public override ServerFeatures Read(ref Utf8JsonReader reader, Type typeToConver ServerFeatures response = new ServerFeatures { - LedgerHash = root.TryGetProperty("ledger_hash", out JsonElement lh) ? lh.GetString() : null, - LedgerIndex = root.TryGetProperty("ledger_index", out JsonElement li) ? li.GetUInt32() : 0, - Validated = root.TryGetProperty("validated", out JsonElement v) && v.GetBoolean() + // Present-but-null is read as absent rather than thrown on: TryGetProperty answers + // true for `"ledger_index": null`, and GetUInt64/GetBoolean then raise, failing the + // whole response over a member the `feature` handler does not even emit. A node that + // sends null is saying it has no value, which is what null on this side means too. + LedgerHash = Text(root, "ledger_hash"), + LedgerIndex = UInt64OrNull(root, "ledger_index"), + Validated = root.TryGetProperty("validated", out JsonElement v) && (v.ValueKind == JsonValueKind.True || v.ValueKind == JsonValueKind.False) + ? v.GetBoolean() + : (bool?)null }; // ───────────────────────────────────────────── diff --git a/Xrpl/Client/Json/Converters/TransactionRequestConverter.cs b/Xrpl/Client/Json/Converters/TransactionRequestConverter.cs index 2c1310c6..53ae33be 100644 --- a/Xrpl/Client/Json/Converters/TransactionRequestConverter.cs +++ b/Xrpl/Client/Json/Converters/TransactionRequestConverter.cs @@ -158,14 +158,11 @@ public override ITransactionRequest Read(ref Utf8JsonReader reader, Type typeToC // Remove this converter to avoid infinite recursion JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); - try - { - return (ITransactionRequest)JsonSerializer.Deserialize(rawJson, transactionRequest.GetType(), innerOptions); - } - catch (JsonException) - { - return transactionRequest; - } + // The JsonException is deliberately not caught. It used to be, returning the bare + // instance created above - a transaction with nothing but its TransactionType set. That + // object is indistinguishable from a real one to everything downstream, including Sign: + // malformed input became a signable, empty transaction instead of an error. + return (ITransactionRequest)JsonSerializer.Deserialize(rawJson, transactionRequest.GetType(), innerOptions); } /// diff --git a/Xrpl/Client/Json/JsonSlice.cs b/Xrpl/Client/Json/JsonSlice.cs new file mode 100644 index 00000000..12ba947c --- /dev/null +++ b/Xrpl/Client/Json/JsonSlice.cs @@ -0,0 +1,230 @@ +using System; +using System.Text; +using System.Text.Json; + +namespace Xrpl.Client.Json +{ + /// + /// Where a JSON token sits inside the buffer it was read from, as a byte offset and length. + /// Carries no reference to the buffer: the envelope that owns the frame pairs the two. + /// + internal readonly struct JsonSlice + { + /// + /// Byte offset of the first byte of the token, counted from the start of the buffer the + /// reader was created over. + /// + public int Offset { get; } + + /// Length of the token in bytes. + public int Length { get; } + + /// True when no token was recorded — the member was absent from the buffer. + public bool IsEmpty => Length == 0; + + /// Records the token's bounds; does not copy or retain the buffer itself. + public JsonSlice(int offset, int length) + { + Offset = offset; + Length = length; + } + + /// + /// Bounds of the sole top-level JSON value in — from its first + /// byte to the byte after it ends, before any trailing whitespace. + /// + /// + /// A stream message is not wrapped in an envelope the way a query response is: the frame + /// is the event, so there is no named member for a per-property converter like + /// to bind to. This computes the same bounds + /// that converter would, for the document as a whole, so a stream event can hand out a + /// the same way an envelope's result does. + /// + /// + /// The buffer holds no JSON value at all - empty, or nothing but whitespace. + /// raises on that rather than returning false, + /// so there is no empty result to hand back. Unreachable through the pipeline, which only + /// attaches a frame it has already deserialized, but the method is reachable directly. + /// + public static JsonSlice OfDocument(byte[] buffer) + { + Utf8JsonReader reader = new Utf8JsonReader(buffer); + reader.Read(); + + long start = reader.TokenStartIndex; + reader.Skip(); + long end = reader.BytesConsumed; + + // A frame holds one value. Anything after it means the buffer is not the single + // document this slice claims to span, and returning the first value's bounds would + // quietly describe part of the input as the whole of it. + if (reader.Read()) + { + throw new JsonException("The buffer holds more than one top-level JSON value; a frame must contain exactly one."); + } + + return new JsonSlice(checked((int)start), checked((int)(end - start))); + } + + /// + /// Bounds of the value of a top-level member named inside + /// , or default (empty) if the object has no such member. + /// + /// + /// + /// For a member that is one of several C# properties bound to the same JSON name via + /// — + /// and its API v1 + /// alias both bind to a name the other also claims — this is the only way to record where + /// the value sits: registering a second member + /// under the same name is not an option, since System.Text.Json rejects two members + /// mapped to one JSON name outright. + /// + /// + /// The scan does not stop at the first match: on a duplicate top-level key it keeps going + /// to and returns the last one, matching + /// 's own last-value-wins behavior for a POCO property fed by a + /// duplicate JSON member (the default unless a caller opts into + /// JsonSerializerOptions.AllowDuplicateProperties = , + /// which this library's does not). Without this, a + /// frame with two top-level tx_json members - not something rippled sends, but not + /// something a proxy or a compromised link is prevented from sending either - would leave + /// pointing at the + /// first occurrence while the deserializer-fed + /// reflects the last: a wallet would display one transaction and sign the other. + /// + /// + /// Matching is case-insensitive, mirroring 's + /// = : + /// a frame that spells the member "TX_JSON" still has to populate + /// , because the + /// same frame already populated the case-insensitively-matched + /// through ordinary + /// deserialization. has no + /// case-insensitive overload, so this decodes the property name through + /// (which unescapes it, same as the property-name + /// matching System.Text.Json itself does internally) and compares with + /// . See + /// for the same rule applied to presence rather + /// than value. + /// + /// + public static JsonSlice FindTopLevelMember(byte[] buffer, ReadOnlySpan name, bool ignoringJsonNull = false) + { + Utf8JsonReader reader = new Utf8JsonReader(buffer); + + if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject) + { + return default; + } + + JsonSlice result = default; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return result; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + bool isMatch = NameMatches(ref reader, name); + reader.Read(); + bool isJsonNull = reader.TokenType == JsonTokenType.Null; + long start = reader.TokenStartIndex; + reader.Skip(); + long end = reader.BytesConsumed; + + // A null-valued occurrence is skipped rather than overwriting what an earlier one + // found, mirroring the `value ?? previous` setters on the typed side: with + // `{"tx_json":{...},"tx_json":null}` the deserializer keeps the object, so + // discarding it here would leave the two views disagreeing again - the same split + // this whole ordering rule exists to close. + if (isMatch && !(ignoringJsonNull && isJsonNull)) + { + result = new JsonSlice(checked((int)start), checked((int)(end - start))); + } + } + + return result; + } + + /// + /// Case-insensitive property-name match that allocates nothing on the ordinary path. + /// + /// + /// This runs for every top-level member of every frame - a stream event passes through it + /// twice - so materializing the name as a string cost ~760 B per scan, on a struct whose + /// entire purpose is to record where a value sits without materializing it. The exact + /// spelling is what a node actually sends, and + /// answers that against the raw bytes; only a differently-cased or escaped name falls + /// through to the allocating comparison, which is what keeps the result identical to the + /// matching System.Text.Json performs + /// under PropertyNameCaseInsensitive. + /// + internal static bool NameMatches(ref Utf8JsonReader reader, ReadOnlySpan name) + { + if (reader.ValueTextEquals(name)) + { + return true; + } + + // An escaped name has to be unescaped before it can be compared, and only GetString() + // does that - but escaped keys are vanishingly rare, so that path pays alone. + if (reader.ValueIsEscaped) + { + return string.Equals(reader.GetString(), Encoding.UTF8.GetString(name), StringComparison.OrdinalIgnoreCase); + } + + return AsciiEqualsIgnoreCase(reader.ValueSpan, name); + } + + /// + /// Compares two UTF-8 spans, folding ASCII letters, without allocating. + /// + /// + /// The names looked up here are ASCII u8 literals (tx_json, transaction, + /// marker), so folding only ASCII is exactly as permissive as the serializer's + /// for anything that could match one: + /// a key differing outside ASCII cannot be case-insensitively equal to an ASCII literal. + /// + /// Doing this in place matters because it runs for every *non-matching* member too. + /// Decoding those cost 1 056 - 1 136 B per scan on an ordinary stream frame - the fast + /// path only helped when the key happened to come first, which for marker (absent + /// on a last page, so every member is walked) was never. + /// + private static bool AsciiEqualsIgnoreCase(ReadOnlySpan left, ReadOnlySpan right) + { + if (left.Length != right.Length) + { + return false; + } + + for (int i = 0; i < left.Length; i++) + { + byte a = left[i]; + byte b = right[i]; + + if (a == b) + { + continue; + } + + // Fold only letters: without this guard '[' (0x5B) would match '{' (0x7B), + // and '_' (0x5F) would match DEL (0x7F) - pairs that differ by that same bit. + int lowered = a | 0x20; + if (lowered < 'a' || lowered > 'z' || lowered != (b | 0x20)) + { + return false; + } + } + + return true; + } + + } +} diff --git a/Xrpl/Client/Json/RawJson.cs b/Xrpl/Client/Json/RawJson.cs new file mode 100644 index 00000000..e00eac7d --- /dev/null +++ b/Xrpl/Client/Json/RawJson.cs @@ -0,0 +1,302 @@ +using System; +using System.Diagnostics; +using System.Text; +using System.Text.Json; + +namespace Xrpl.Client.Json +{ + /// + /// The bytes a node actually sent for one member of a response, as they arrived. + /// + /// + /// A window onto the frame rather than a copy of it: the frame is the exact-sized array the + /// receive loop already allocated, so holding this costs nothing beyond keeping that array + /// alive. UTF-16 is never stored — builds it on demand, which for a + /// large response is twice the byte length and worth paying only when something needs text. + /// The window keeps the whole frame alive, not just the bytes it spans: for the result member + /// that is the frame anyway, but a small window onto a large frame pins all of it. Anything + /// outliving the response — a stored page, an entry cached across a paged crawl — should keep + /// instead and let the frame go. + /// + [DebuggerDisplay("RawJson, {Length} bytes")] + public readonly struct RawJson : IEquatable + { + private readonly byte[]? _frame; + private readonly int _offset; + private readonly int _length; + + /// Records the window; does not copy the frame. + /// + /// The window is checked to hold exactly one JSON value, because + /// writes it through without validating - a partial or malformed + /// window would silently corrupt the document it is written into. The check happens here, + /// once, rather than on every write: runs per response on a paged + /// crawl, and validating there costs about 10x (measured 3.26 -> 29.45 us on a 36 KB + /// window). + /// + /// Note the frame is aliased, not copied. Mutating the array after construction changes + /// what this window reads, and no check can catch that - use to + /// detach if the buffer is not yours alone. + /// + /// + /// + /// The window does not lie inside . + /// + /// + /// The window is not exactly one well-formed JSON value. + /// + public RawJson(byte[]? frame, int offset, int length) + : this(frame, offset, length, validate: true) + { + } + + /// + /// Records a window the SDK already parsed, skipping the well-formedness check. + /// + /// + /// For bounds produced by or + /// , which reach them through Utf8JsonReader.Skip() - the + /// value is well-formed by construction, and these run on every property read. Bounds are + /// still checked; only the content scan is skipped. + /// + internal static RawJson Trusted(byte[]? frame, int offset, int length) + => new RawJson(frame, offset, length, validate: false); + + private RawJson(byte[]? frame, int offset, int length, bool validate) + { + // Bounds come from JsonSliceConverter and are relative to the buffer its reader was + // created over, not to the array behind it. Pairing a slice with a different buffer is + // the one way this type breaks, and it breaks silently: an in-range window over the + // wrong bytes reads back as valid JSON. Checking here fails where the pairing is made, + // instead of as an unnamed ArgumentOutOfRangeException inside a consumer's Span read. + if (frame is null) + { + if (offset != 0 || length != 0) + { + throw new ArgumentNullException(nameof(frame)); + } + } + else if ((uint)offset > (uint)frame.Length || (uint)length > (uint)(frame.Length - offset)) + { + // Name the argument actually at fault: an out-of-range offset leaves length blameless, + // and reporting it as the culprit sends the reader after the wrong number. + throw new ArgumentOutOfRangeException( + (uint)offset > (uint)frame.Length ? nameof(offset) : nameof(length), + $"Window [{offset}, {offset + (long)length}) does not lie inside a frame of {frame.Length} bytes."); + } + + _frame = frame; + _offset = offset; + _length = length; + + if (validate && frame is not null && length > 0) + { + ValidateSingleValue(frame.AsSpan(offset, length)); + } + } + + /// + /// Verifies the window holds exactly one JSON value - no trailing content beyond it. + /// + private static void ValidateSingleValue(ReadOnlySpan window) + { + Utf8JsonReader reader = new Utf8JsonReader(window); + + if (!reader.Read()) + { + throw new JsonException("The window holds no JSON value."); + } + + reader.Skip(); + + if (reader.Read()) + { + throw new JsonException("The window holds more than one JSON value; it must hold exactly one."); + } + } + + /// True when nothing was captured. + public bool IsEmpty => _frame is null || _length == 0; + + /// Length of the captured JSON in bytes. + public int Length => _frame is null ? 0 : _length; + + /// The captured JSON, as UTF-8, without copying. + public ReadOnlySpan Span => _frame is null ? default : _frame.AsSpan(_offset, _length); + + /// + /// Copies the captured JSON into a new array, detaching it from the frame. This is how a + /// consumer keeps the bytes past the response without pinning the whole frame with them. + /// + public byte[] ToArray() => _frame is null ? Array.Empty() : Span.ToArray(); + + /// + /// Deserializes the captured JSON into using the library's + /// serializer options. + /// + /// + /// Here so that a consumer does not reach for JsonSerializer.Deserialize with + /// options of their own: the XRPL models depend on the converters in + /// , and bare options silently produce a different + /// object. Returns default for an empty window rather than throwing — an absent + /// member is not a malformed one. That default is ambiguous for a value type, where it + /// coincides with a legitimately-parsed zero; is what tells the two + /// apart. + /// + public T? Deserialize() + { + return IsEmpty ? default : JsonSerializer.Deserialize(Span, XrplJsonOptions.Default); + } + + /// + /// Parses the captured JSON into a self-contained . + /// + /// + /// The element copies out of the frame, so it stays readable after the frame is gone — + /// unlike , which aliases it. An empty window yields + /// . Parses over + /// directly rather than through : JsonDocument.Parse does not + /// copy a memory argument, so going through ToArray first would pay for a copy this + /// call does not need — is what makes the result + /// self-contained, and that is the only copy that has to happen. + /// + public JsonElement ToJsonElement() + { + if (_frame is null || _length == 0) + { + return default; + } + + using (JsonDocument document = JsonDocument.Parse(_frame.AsMemory(_offset, _length))) + { + return document.RootElement.Clone(); + } + } + + /// + /// True when the captured JSON is an object carrying at its top + /// level. + /// + /// + /// Each non-matching member's value is skipped whole, so a nested occurrence of the name + /// cannot be mistaken for a top-level one. Names are matched by + /// : case-insensitively, mirroring + /// 's + /// = , + /// and without allocating for anything but an escaped name - this runs on every paged + /// response through HasNextPage. + /// Presence does not depend on which occurrence is meant, unlike a value lookup, so unlike + /// this still returns as soon as a match is + /// found instead of scanning to the end for the last one. + /// + /// + /// The window does not hold well-formed JSON. Unreachable for a the + /// SDK produced - those windows come from a document it already parsed - but this type is + /// public and constructible over arbitrary bytes. + /// + public bool HasTopLevelProperty(ReadOnlySpan name) + { + if (IsEmpty) + { + return false; + } + + Utf8JsonReader reader = new Utf8JsonReader(Span); + + if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject) + { + return false; + } + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return false; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + // Zero-allocation on the spelling a node actually sends; only a differently-cased + // or escaped key pays for a string. This runs on every paged response through + // HasNextPage, so the fast path is the point - see JsonSlice.NameMatches, which + // carries the same rule for locating a member rather than proving one is present. + if (JsonSlice.NameMatches(ref reader, name)) + { + return true; + } + + reader.Skip(); + } + + return false; + } + + /// Writes the captured JSON into verbatim. + public void WriteTo(Utf8JsonWriter writer) + { + if (writer is null) + { + throw new ArgumentNullException(nameof(writer)); + } + + if (IsEmpty) + { + // Covers the shape an absent member produces: the slice stays default (0, 0) and is + // still paired with a live frame. WriteRawValue rejects an empty span outright, and + // does so before skipInputValidation is consulted. + writer.WriteNullValue(); + return; + } + + // The payload was already validated as JSON when the envelope was parsed - the + // converter reached these bounds through reader.Skip(). Re-validating here would mean + // parsing the subtree a second time, which is the cost this type exists to remove: + // measured at 0.20 -> 2.16 us on a 1 KB window and 3.26 -> 29.45 us on a 36 KB one, + // about 10x, on a path a paged crawl runs per response. + // + // The premise is therefore the window's correctness. For any RawJson the SDK produced + // that holds by construction. For one built through the public constructor over + // arbitrary bytes it does not: bounds are checked, contents are not, so a window over + // malformed or partial JSON is written through verbatim and silently corrupts the + // containing document. Validate before constructing if the bytes did not come from a + // parsed response. + writer.WriteRawValue(Span, skipInputValidation: true); + } + + /// + /// Decodes the captured JSON as UTF-16 text. Allocates; call only when text is needed. + /// This is a decode of the bytes, not the byte-exact source — invalid UTF-8 is replaced + /// with U+FFFD. For the bytes as the node sent them, use . + /// + public override string ToString() + { + return _frame is null ? string.Empty : Encoding.UTF8.GetString(_frame, _offset, _length); + } + + /// + /// Identity, not content: two windows are equal when they address the same bytes of the + /// same frame. Comparing the bytes themselves is what is for. Without + /// this the default struct equality reflects over the fields to reach the same answer, + /// boxing both operands, and hashes on the frame reference alone - so two different + /// windows onto one frame land in the same bucket. + /// + public bool Equals(RawJson other) => + ReferenceEquals(_frame, other._frame) && _offset == other._offset && _length == other._length; + + /// + public override bool Equals(object? obj) => obj is RawJson other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(_frame?.GetHashCode() ?? 0, _offset, _length); + + /// Identity comparison; see . + public static bool operator ==(RawJson left, RawJson right) => left.Equals(right); + + /// Identity comparison; see . + public static bool operator !=(RawJson left, RawJson right) => !left.Equals(right); + } +} diff --git a/Xrpl/Client/RequestManager.cs b/Xrpl/Client/RequestManager.cs index 74c1b60d..1698cf77 100644 --- a/Xrpl/Client/RequestManager.cs +++ b/Xrpl/Client/RequestManager.cs @@ -1,10 +1,12 @@ using NBitcoin.Protocol; using System; +using System.Buffers.Text; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Reflection; +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading; @@ -41,7 +43,7 @@ public class XrplRequest { public Guid Id { get; set; } public string Message { get; set; } - public Task> Promise { get; set; } + public Task Promise { get; set; } } public class XrplGRequest @@ -59,15 +61,7 @@ public class XrplGRequest /// 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(); - } - } + private static ReadOnlySpan EmptyResult => "{}"u8; public RequestManager() { @@ -88,8 +82,8 @@ public void Resolve(Guid id, BaseResponse response) try { - object deserialized = DeserializeResult(response.Result, taskInfo.Type); - CompleteWithResult(taskInfo, deserialized); + object deserialized = DeserializeResult(response.RawResult, taskInfo.Type); + CompleteWithResult(taskInfo, new ResolvedResponse(deserialized, response)); this.DeletePromise(id, taskInfo); } catch (Exception ex) @@ -102,43 +96,25 @@ public void Resolve(Guid id, BaseResponse response) /// /// Converts the result member of a response into the type the request was created - /// with. + /// with, parsing it straight out of the frame. /// /// - /// 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. + /// The member is not parsed before this point: the envelope only recorded where it sits. + /// That leaves exactly one parse of the response body, against the UTF-8 the node sent, + /// with no intermediate document and no pooled array left unreturned. /// - private object DeserializeResult(object result, Type type) + private object DeserializeResult(RawJson raw, Type type) { - JsonElement element; + ReadOnlySpan json = raw.IsEmpty ? EmptyResult : raw.Span; - if (result is null) - { - element = EmptyResult; - } - else if (result is JsonElement parsed) + // An explicit `"result": null` arrives as a four-byte literal; it used to reach the + // requested type as an empty object rather than null, and callers rely on that. + if (json.SequenceEqual("null"u8)) { - 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; + json = EmptyResult; } - return element.Deserialize(type, serializerOptions); + return JsonSerializer.Deserialize(json, type, serializerOptions); } /// @@ -416,11 +392,11 @@ public XrplRequest CreateRequest( string newRequest = JsonSerializer.Serialize(request, serializerOptions); string outgoingRequest = ApplyAdminCredentials(newRequest, adminCredentials); - TaskCompletionSource> task = new TaskCompletionSource>(); + TaskCompletionSource task = new TaskCompletionSource(); TaskInfo taskInfo = new TaskInfo(); taskInfo.TaskId = newId; taskInfo.TaskCompletionResult = task; - taskInfo.SetResult = result => task.TrySetResult((Dictionary)result); + taskInfo.SetResult = result => task.TrySetResult(result); taskInfo.SetException = error => task.TrySetException(error); taskInfo.CompletionTask = task.Task; taskInfo.RemoveUponCompletion = true; @@ -499,31 +475,84 @@ public XrplRequest CreateRequest( /// public (BaseResponse Response, bool Handled) HandleResponse(string message) { - return HandleResponse(JsonSerializer.Deserialize(message, serializerOptions)); + return HandleResponse(Encoding.UTF8.GetBytes(message)); } /// - /// Same as for a message still in its wire form. + /// Handles a message still in its wire form. This is the socket path. /// /// - /// 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. + /// The frame is kept rather than sliced away: the envelope records where result sits + /// inside it, and both the typed deserialization and + /// are cut from those bounds. The array is the exact-sized one the receive loop already + /// allocated, so keeping it costs nothing over what was allocated anyway. /// - public (BaseResponse Response, bool Handled) HandleResponse(ReadOnlySpan utf8Message) + /// + /// The message bytes. Ownership passes to the returned response: it keeps the array and cuts + /// from it, so the caller must not reuse or mutate it — + /// a pooled or ring buffer will silently rewrite a response that was already handed out. + /// + public (BaseResponse Response, bool Handled) HandleResponse(byte[] frame) { - return HandleResponse(JsonSerializer.Deserialize(utf8Message, serializerOptions)); + ErrorResponse response = JsonSerializer.Deserialize(frame, serializerOptions); + // A frame that is the bare JSON literal `null` deserializes to a null ErrorResponse + // rather than throwing - System.Text.Json's contract for a reference type. That is a + // malformed protocol response (a node or intermediary proxy sending no object at all), + // not an internal bug, so it must surface as a typed protocol error instead of an NRE + // out of the next line. + if (response is null) + { + throw new XrplException("Response frame did not contain a JSON object (received JSON null)."); + } + + response.AttachFrame(frame); + return HandleResponse(response); } - private (BaseResponse Response, bool Handled) HandleResponse(ErrorResponse response) + /// + /// Parses the id member straight out of its recorded bytes - no intermediate + /// string, matching how reads result straight out + /// of the frame. + /// + /// + /// This SDK always sends a , which System.Text.Json serializes as a + /// quoted "D"-format string - the shape expects by default. A + /// bare JSON number is protocol-legal but never sent by this SDK; it fails the leading- + /// quote check below and falls through as "not matched", the same outcome + /// Guid.TryParse on the id's formatted text used to produce for it. An absent or + /// explicit-null id both leave either empty or not + /// starting with a quote, so both land here too. + /// + private static bool TryParseIdAsGuid(RawJson rawId, out Guid id) { - if (response.Id == null) + id = default; + + if (rawId.IsEmpty) { - return (response, false); + return false; } - if(!Guid.TryParse($"{response.Id}", out var id)) + ReadOnlySpan span = rawId.Span; + + // The slice includes the surrounding quotes of a JSON string token (see + // JsonSliceConverter) - strip them before handing the content to Utf8Parser, which + // expects bare Guid text, not a JSON string literal. + if (span.Length < 2 || span[0] != (byte)'"' || span[^1] != (byte)'"') + { + return false; + } + + ReadOnlySpan content = span[1..^1]; + + // bytesConsumed must equal the whole content, not just a valid prefix of it - a + // partial match (e.g. trailing garbage) is not a real id. + return Utf8Parser.TryParse(content, out id, out int bytesConsumed) + && bytesConsumed == content.Length; + } + + private (BaseResponse Response, bool Handled) HandleResponse(ErrorResponse response) + { + if (!TryParseIdAsGuid(response.RawId, out Guid id)) { return (response, false); } diff --git a/Xrpl/Client/ResolvedResponse.cs b/Xrpl/Client/ResolvedResponse.cs new file mode 100644 index 00000000..b930371f --- /dev/null +++ b/Xrpl/Client/ResolvedResponse.cs @@ -0,0 +1,33 @@ +using Xrpl.Models.Subscriptions; + +namespace Xrpl.Client +{ + /// + /// What a resolved request puts into its promise: the typed result and the envelope it came + /// from, together. + /// + /// + /// The promise is Task<object> and knows the target + /// type only as a , so it cannot build a + /// itself. It carries both halves this far and the generic + /// client assembles them, which keeps the manager free of the generic parameter. + /// + /// Public because and its request objects are: their + /// Promise is a Task<object> that resolves to this, and a caller working at + /// that level has to be able to name the type it gets back. Callers of the client's own + /// methods never see this — they get . + /// + /// + public sealed class ResolvedResponse + { + public ResolvedResponse(object result, BaseResponse envelope) + { + Result = result; + Envelope = envelope; + } + + public object Result { get; } + + public BaseResponse Envelope { get; } + } +} diff --git a/Xrpl/Client/WebSocketClient.cs b/Xrpl/Client/WebSocketClient.cs index e4ae5693..ca59f487 100644 --- a/Xrpl/Client/WebSocketClient.cs +++ b/Xrpl/Client/WebSocketClient.cs @@ -194,9 +194,9 @@ internal WebSocketClient OnConnectionError(Func - /// Set the Action to call when the connection fails. + /// Set the Action to call when the socket reports an error. /// - /// The Action to call + /// The Action to call /// Self internal WebSocketClient OnError(Func onError) { @@ -539,6 +539,30 @@ private async Task ReceiveLoopAsync() CallOnMessage(completeMessage); } + + // Reached only when the loop ended without throwing - its condition went false + // between iterations. Every other way out of this method reports the close; this + // one reported nothing, so a caller waiting to hear that the connection ended + // simply never heard. + // + // It is not a rare corner. A message handler runs inline here, on this 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 CallOnMessage + // above. The loop comes back round to find the cancellation already set, leaves by + // its condition, and the disconnect is never announced. That is what made a user + // Disconnect() silent against a fast peer while looking correct against a slow one. + // + // Classified the same way as the catches below: cancelled or disposed is a normal + // close, a socket that simply stopped being Open is a drop. + if (_isIntentionalDisconnect || _cancellationToken.IsCancellationRequested || IsDisposed) + { + await CallOnDisconnectedAsync(WebSocketCloseStatus.NormalClosure, "Client disconnected").ConfigureAwait(false); + } + else + { + FailureReason = SocketFailureReason.NetworkDrop; + await CallOnDisconnectedAsync(WebSocketCloseStatus.EndpointUnavailable, "Connection is no longer open").ConfigureAwait(false); + } } catch (OperationCanceledException) when (_isIntentionalDisconnect || _cancellationToken.IsCancellationRequested || IsDisposed) { diff --git a/Xrpl/Client/XrplResponse.cs b/Xrpl/Client/XrplResponse.cs new file mode 100644 index 00000000..71add417 --- /dev/null +++ b/Xrpl/Client/XrplResponse.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; + +using Xrpl.Client.Exceptions; +using Xrpl.Client.Json; +using Xrpl.Models.Subscriptions; + +namespace Xrpl.Client +{ + /// + /// A response from a node: the typed projection of its result, and the bytes that + /// projection was made from. + /// + /// + /// The pair exists because the projection is lossy in both directions and cannot be turned + /// back into what arrived: members the model does not know are dropped, and non-nullable CLR + /// properties re-serialize as zeros the node never sent. Anything that has to show or verify + /// what a node actually said — a wallet rendering a transaction for signing — reads + /// ; everything else reads . + /// + /// There is deliberately no implicit conversion to . Measured against + /// this codebase it would carry fewer than half the call sites — those with an explicit type; + /// the ones using var break regardless — leaving a partial compatibility that is harder + /// to migrate than a clean break, and it would hide that exists at all. + /// + /// + public readonly struct XrplResponse + { + private readonly IReadOnlyList _warnings; + + /// Pairs a typed result with the envelope it was read from. + public XrplResponse( + T result, + RawJson raw, + uint? apiVersion, + string status, + string warning, + IReadOnlyList warnings, + bool forwarded) + { + Result = result; + Raw = raw; + ApiVersion = apiVersion; + Status = status; + Warning = warning; + _warnings = warnings; + Forwarded = forwarded; + } + + /// The result member, projected onto the requested type. + public T Result { get; } + + /// + /// The result member exactly as the node sent it. Empty when the response carried + /// none. + /// + public RawJson Raw { get; } + + /// The API version the node answered on, when it reported one. + public uint? ApiVersion { get; } + + /// + /// "success", or "error" if the request caused one. + /// + /// + /// A separate member for the same reason is: status sits + /// beside result in the envelope, not inside it, so it is not reachable through + /// is a slice of result alone. + /// + public string Status { get; } + + /// + /// The node's rate-limit signal, when it sent one — the literal "load", meaning this + /// client is approaching the threshold at which the server will disconnect it. + /// + /// + /// A separate member from because rippled reports it separately, and + /// it is not reachable through either: that is the result member, + /// while this lives in the envelope around it. + /// + public string Warning { get; } + + /// Warnings the node attached to this response. Never null. + /// + /// rippled attaches these under load and on a reporting-mode server. Before this type they + /// did not reach the caller at all — the envelope was unwrapped and discarded. + /// + public IReadOnlyList Warnings => _warnings ?? Array.Empty(); + + /// + /// True when a Reporting Mode server forwarded this request to a P2P server and back. + /// + public bool Forwarded { get; } + + /// + /// True when the node reported a marker, meaning more pages follow. + /// + /// + /// The extension on is no longer reachable from here — a caller + /// holds this type now, not the envelope — and paging is the case this whole change was + /// made for. + /// + public bool HasNextPage => Raw.HasTopLevelProperty("marker"u8); + + /// + /// Lets a caller take both halves at once: var (info, raw) = await client.AccountInfo(request). + /// + /// + /// The call sites that broke hardest on this change are the ones using var, and this + /// is what makes them a one-line edit rather than a restructure. + /// + public void Deconstruct(out T result, out RawJson raw) + { + result = Result; + raw = Raw; + } + } + + /// + /// Builds from what a resolved request left in its promise. + /// + public static class XrplResponse + { + /// + /// Unpacks the pair the request manager put into the promise. + /// + /// + /// The manager knows the target type only as a , so it cannot + /// build the generic response itself and carries both halves instead. This is where they + /// come back together — inside the connection, so that no public method hands out an + /// object the caller has no type to name. + /// + /// Public because is: a caller working directly against + /// — rather than through the client's own methods, which + /// already return — gets a Promise that resolves to a + /// , and this is the supported way to turn that back into a + /// typed . + /// + /// + public static XrplResponse From(object resolved) + { + if (resolved is not ResolvedResponse carried) + { + throw new XrplException( + $"A resolved request carried {resolved?.GetType().Name ?? "null"} instead of its response envelope."); + } + + return From(carried); + } + + /// + /// Unpacks a already in hand. + /// + /// + /// The object overload exists because Promise is typed Task<object> and cannot hand out + /// anything more specific. A caller that already has the — + /// having awaited the promise itself — should use this overload instead: the mismatch this + /// type carries the most (a Promise that resolved to something other than what the + /// request was created with) becomes a compile error here rather than the + /// the other overload has to throw at run time. + /// + public static XrplResponse From(ResolvedResponse resolved) + { + T typed; + switch (resolved.Result) + { + case T match: + typed = match; + break; + // A null Result is a legitimate match for a reference type or Nullable - the + // pattern above cannot express that, so it is handled separately rather than + // folded into the mismatch case below. + case null when default(T) is null: + typed = default!; + break; + default: + // Same failure the object overload above raises for a mismatched Promise - + // unified here instead of leaving this overload to throw a bare + // InvalidCastException from an unchecked (T) cast. + throw new XrplException( + $"A resolved request carried {resolved.Result?.GetType().Name ?? "null"} instead of the expected {typeof(T).Name}."); + } + + BaseResponse envelope = resolved.Envelope; + + return new XrplResponse( + typed, + envelope?.RawResult ?? default, + envelope?.ApiVersion, + envelope?.Status, + envelope?.Warning, + envelope?.Warnings, + envelope?.Forwarded ?? false); + } + } +} diff --git a/Xrpl/Client/XrplResponseTaskExtensions.cs b/Xrpl/Client/XrplResponseTaskExtensions.cs new file mode 100644 index 00000000..05ac7952 --- /dev/null +++ b/Xrpl/Client/XrplResponseTaskExtensions.cs @@ -0,0 +1,38 @@ +using System.Threading.Tasks; + +namespace Xrpl.Client +{ + /// + /// Awaiting helpers for the a client method returns. + /// + /// + /// Reading the projection off an awaited call otherwise reads + /// (await client.ServerFeatures()).Result: the call has to be parenthesised so the + /// member access lands on the awaited value rather than the task. That is unlike ordinary + /// awaiting, and the member it reaches is spelled the same as + /// - which blocks - so the line reads as sync-over-async to anyone scanning it, and to + /// analyzers looking for exactly that shape. Neither is true here. + /// + /// await client.ServerFeatures().Typed() puts the await back where it belongs. The + /// three forms are equivalent; use whichever fits: + /// + /// ServerFeatures f = await client.ServerFeatures().Typed(); // projection only + /// var (f, raw) = await client.ServerFeatures(); // both + /// XrplResponse<ServerFeatures> r = await client.ServerFeatures(); // the envelope + /// + /// + /// + public static class XrplResponseTaskExtensions + { + /// + /// Awaits the call and hands back the typed projection alone. + /// + /// + /// The projection is lossy in both directions - see . Anything + /// that has to show or verify what the node actually said needs + /// , so await the call itself rather than using this. + /// + public static async Task Typed(this Task> response) + => (await response.ConfigureAwait(false)).Result; + } +} diff --git a/Xrpl/Client/connection.cs b/Xrpl/Client/connection.cs index e330e7fb..0d88f35e 100644 --- a/Xrpl/Client/connection.cs +++ b/Xrpl/Client/connection.cs @@ -83,6 +83,9 @@ public class Connection public event OnDisconnect OnDisconnect; + /// + public event OnSessionEnded OnSessionEnded; + public event OnPing OnPing; public event OnLedgerClosed OnLedgerClosed; @@ -255,6 +258,21 @@ public class ConnectionOptions /// Default: 30 seconds. /// public TimeSpan ConnectionAcquisitionTimeout { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// How many stream messages may wait for the consumer before the oldest are discarded. + /// + /// + /// Stream events are handed to a background reader through a bounded channel, so a slow + /// handler never blocks the receive loop. What it does instead is fall behind, and past + /// this many queued messages the oldest are dropped to make room - + /// counts them. + /// + /// Raise it for a consumer that must not miss events and can absorb the memory (each slot + /// holds one frame); lower it to bound memory harder, accepting more loss. Default 10 000. + /// + /// + public int StreamMessageQueueCapacity { get; set; } = 10000; } private void ValidateConfig() @@ -339,14 +357,14 @@ private static WebSocketClient CreateWebSocket(string url, ConnectionOptions con /// the ownership-guarded writes in . /// /// + /// /// Not every touch of these fields is covered: the per-iteration _reconnectAttempts++ in /// , the plain resets in ChangeServer and /// OnceClose, and the "is a loop already running" pre-checks in /// OnConnectionFailed and OnceClose (which read _reconnectLoop, a /// non-volatile field, outside the lock) all still run outside it. Those predate this lock; do /// not read the list above as "all three fields are always synchronized". - /// - /// + /// /// /// volatile alone was not enough: it makes each individual access atomic, not the /// sequence of them. The stop path used to read the field three times in a row (Cancel, @@ -400,7 +418,161 @@ private static WebSocketClient CreateWebSocket(string url, ConnectionOptions con // to prevent head-of-line blocking that causes ping timeouts under high stream load // Channel is created per-session to prevent cross-session message leakage // Using Channel instead of BlockingCollection for true async support in WebAssembly - private Channel? _streamMessageChannel = null; + // Carries the raw frame bytes, not text: stream events pair themselves with the frame the same + // way a query response does, through AttachFrame, so their Raw/RawTransaction is available + // without a second UTF-8 encode of a string that was itself decoded from these same bytes. + // The frame travels wrapped in SessionFrame, which names the session that produced it. + private Channel? _streamMessageChannel = null; + + private long _droppedStreamMessages; + + private long _staleSessionFramesDropped; + + private long _fallbackDispatchedStreamMessages; + + /// + /// How many stream frames were dispatched outside the queue. + /// + /// + /// The fallback path holds none of the queue's guarantees: no capacity bound, no eviction + /// counting, no single-reader ordering. Three things send a frame down it - the processor not + /// being up yet, the processor having been stopped, and the channel refusing a write because + /// its writer is already completed - and none of them were visible from outside until this + /// counter existed. + /// + /// Cumulative over the life of the connection, and a non-zero value is not by itself a fault: + /// a client that was driven before it connected, or after it disconnected, legitimately has + /// frames here. What means something is an increase across a connect: the startup window this + /// counter was added to measure is closed, so the number should not move while a connection is + /// being established. + /// + /// + public long FallbackDispatchedStreamMessages => Interlocked.Read(ref _fallbackDispatchedStreamMessages); + + /// + /// How many stream frames were discarded because they came from a session that is no longer + /// active. + /// + /// + /// A socket being retired keeps delivering until its graceful close finishes, so a handful of + /// frames can arrive after a reconnect or ChangeServer has already moved on. They are + /// dropped rather than delivered: after a change of network they would otherwise describe a + /// different chain entirely. Counted separately from + /// , which is about consumers falling behind - these two + /// mean different things and a non-zero value here is normal right after a reconnect. + /// + public long StaleSessionFramesDropped => Interlocked.Read(ref _staleSessionFramesDropped); + + /// + /// The id of the session serving this connection, or if there is none. + /// + /// + /// Exists for tests, which need to name the session a frame came from - something only the + /// socket callbacks can otherwise do. + /// + internal long? ActiveSessionId + { + get + { + lock (_sessionLock) + { + return _activeSession?.SessionId; + } + } + } + + /// + /// Whether the background message processor is up, i.e. whether stream frames are queued + /// rather than taking the fallback path. + /// + /// + /// Exists for tests. OnceOpen now starts the processor before it resolves the waiters + /// through connectionManager.ResolveAllAwaiting() and before the OnConnected + /// callback, so a returned Connect() does imply a queue - it did not until the ping + /// timer stopped taking the processor down with it. + /// + /// Volatile.Read rather than a plain read or _messageProcessorLock: the field is + /// written under that lock, and holding it here would mean waiting out + /// StopMessageProcessorInternal, which blocks up to two seconds on the reader task. + /// + /// + internal bool IsMessageProcessorRunning => Volatile.Read(ref _streamMessageChannel) != null; + + /// + /// Whether the ping timer is up. + /// + /// + /// Exists for tests, and for one question in particular: the ping timer starts at the very end + /// of OnceOpen, after Connect() has already returned, so a test that wants to + /// know the processor survived has to wait for it rather than + /// assume it. Without that wait such a test can pass by asserting too early - before the thing + /// it is testing has had a chance to go wrong. + /// + /// The signal is exact for that purpose: begins by calling + /// , which clears this field, and only assigns it afterwards. + /// Seeing it non-null therefore means the teardown step - the one that used to take the + /// message processor with it - is already behind us. + /// + /// + internal bool IsPingTimerRunning => Volatile.Read(ref _pingCts) != null; + + /// + /// Completes the stream channel's writer without clearing the channel, reproducing the state + /// StopMessageProcessorInternal leaves behind for anyone who read + /// _streamMessageChannel just before it was cleared. + /// + /// + /// Exists for tests: in production that state lasts between one field read and one call, and + /// is not reachable deliberately. + /// + internal void CompleteStreamChannelWriterForTests() + { + _streamMessageChannel?.Writer.Complete(); + } + + /// + /// Marks the session serving this connection as retiring, reproducing the window + /// ChangeServer and the reconnect loop open between MarkAsRetiring() and the + /// installation of the replacement session. + /// + /// + /// Exists for tests: in production that window is reachable only by racing a real reconnect. + /// Marks it exactly the way the two production paths do, under _sessionLock. + /// + /// Returns the id rather than leaving the caller to read + /// separately: two lock acquisitions would let a reconnect swap the session in between, and a + /// test that then named the id it read first would be exercising the mismatch path it was + /// written to avoid - silently, and only sometimes. + /// + /// + /// + /// The id of the session that was marked, or if there is none. + /// + internal long? MarkActiveSessionRetiringForTests() + { + lock (_sessionLock) + { + _activeSession?.MarkAsRetiring(); + return _activeSession?.SessionId; + } + } + + /// + /// How many stream messages have been discarded because the consumer fell behind. + /// + /// + /// The queue feeding stream handlers is bounded (see + /// ) and discards the oldest message + /// when full, so a slow handler costs events rather than stalling the socket. That discard used + /// to be entirely silent: nothing threw, nothing logged, and a consumer building state from the + /// stream simply drifted from the ledger with no way to notice. This counter is the way to + /// notice - non-zero and rising means handlers are not keeping up. + /// + /// Counts across the lifetime of this connection, including across reconnects and + /// ChangeServer, since the same object serves them all. + /// + /// + public long DroppedStreamMessages => Interlocked.Read(ref _droppedStreamMessages); private CancellationTokenSource? _messageProcessorCts = null; private Task? _messageProcessorTask = null; private readonly object _messageProcessorLock = new(); @@ -474,6 +646,72 @@ private void SetConnectionState( } } + /// + /// Announces that has ended, once and only once. + /// + /// + /// + /// A session that ends because its socket closed is announced from , + /// alongside . The two paths that retire a session deliberately - + /// and - mark it + /// retiring before the socket goes, which makes return early by design, + /// so each has to speak for itself. For the fast-reconnect path a + /// status at least went out; for + /// nothing did, and that is issue #123: the client reported + /// Connected while the subscriptions, which the node keeps against the old connection, + /// were gone for good. + /// + /// + /// The once-per-session guard lives on the session rather than here, for a narrow race the + /// retiring check does not cover: a socket can close by itself in the moment before the + /// retirement marks its session, and then sees a live session and + /// announces the loss just as the retirement announces the switch. One session, two causes, + /// and the consumer must still hear about it once. + /// + /// + /// A throwing consumer handler must not reach the caller. calls + /// this after it has torn the old socket down and before it connects the new one, so an + /// escaping exception would leave the client with no socket, no session and no reconnect + /// loop - the same reason contains its handler. + /// + /// + /// The session that ended; null when there was none, and then + /// nothing is announced. + /// What ended it. + /// Human-readable detail, for the consumer's log. + private async Task NotifySessionEndedAsync( + ConnectionSession? session, + SessionEndReason reason, + string description) + { + // A session that never got a connected socket carries nothing to lose, and its close + // callback would otherwise announce an end for every failed retry. + if (session?.IsOpened != true) + { + return; + } + + if (!session.TryMarkEndNotified()) + { + return; + } + + OnSessionEnded handler = OnSessionEnded; + if (handler is null) + { + return; + } + + try + { + await handler.Invoke(reason, description); + } + catch (Exception notifyError) + { + Debug.WriteLine($"{DateTime.Now}OnSessionEnded handler threw for {reason}: {notifyError.Message}"); + } + } + private ReconnectInfo BuildReconnectInfo(int? explicitAttempt = null, TimeSpan? delay = null) { var attempt = explicitAttempt ?? _reconnectAttempts; @@ -524,8 +762,11 @@ public async Task ChangeServer( // 1. Quick state cleanup - stop reconnect loop StopReconnectLoop(); - // 2. Cancel ping timer (but don't wait yet) + // 2. Cancel ping timer and the message processor (but don't wait yet). Stopping the + // processor is explicit since StopPingTimerSync no longer does it as a side effect - this + // session's queue goes with the session. StopPingTimerSync(); + StopMessageProcessor(); // 3. Reject all pending requests BEFORE waiting for ping // This allows the ping handler to receive OperationCanceledException and exit quickly @@ -568,6 +809,17 @@ public async Task ChangeServer( _ = RetireOldSessionAsync(oldSession, oldSocket); } + // The consumer's subscriptions belonged to the session just retired and do not follow the + // client to the new server. Nothing else on this path says so - the socket's own close + // callback is filtered out as a retiring session, and the status notification above reads + // Connecting, which is what a first connection reports too. Announced before the new + // connection is opened, so a consumer cannot see OnConnected for the new session and only + // afterwards learn that the old one is gone. + await NotifySessionEndedAsync( + oldSession, + SessionEndReason.ServerChanged, + $"Switched to {server}. Subscriptions from the previous connection are no longer in effect."); + // 7. Update config for new server url = server; if (options != null) @@ -660,8 +912,10 @@ private async Task RetireCurrentSessionAndReconnectAsync(string reason) // New session is created immediately without waiting. // Callbacks check session ID to ignore retiring sessions. - // 4. Stop ping timer (but don't wait yet) + // 4. Stop ping timer and the message processor (but don't wait yet) - the queue belongs + // to the session being retired. StopPingTimerSync(); + StopMessageProcessor(); // 5. Reject all pending requests BEFORE waiting for ping // This allows the ping handler to receive OperationCanceledException and exit quickly @@ -703,6 +957,13 @@ private async Task RetireCurrentSessionAndReconnectAsync(string reason) _ = RetireOldSessionAsync(oldSession, oldSocket); } + // Same as ChangeServer: the session being retired took the subscriptions with it, and the + // socket's own close callback will be filtered out as retiring. The RestoringConnection + // status above reports that 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. + await NotifySessionEndedAsync(oldSession, SessionEndReason.ConnectionLost, reason) + .ConfigureAwait(false); + // 10. Clear ping/network drop socket tracking (old socket is retired) // CRITICAL: If not cleared, these stale references would cause OnConnectionFailed // to filter callbacks from the NEW socket if Connect() fails, blocking reconnection. @@ -1069,8 +1330,11 @@ await errorHandler.Invoke( try { // Use fast-path processing to prioritize ping/pong responses - // and prevent head-of-line blocking from high-volume stream data - await IOnMessageFastPath(m); + // and prevent head-of-line blocking from high-volume stream data. + // The session travels with the frame so a late arrival from a socket being + // retired can be told apart from one on the live connection - see + // EnqueueStreamMessage. + await IOnMessageFastPath(m, capturedSession.SessionId); } catch (Exception ex) { @@ -1120,6 +1384,7 @@ public async Task Disconnect() ClearReconnectState(); // Clear all reconnect state on user disconnect StopPingTimerSync(); + StopMessageProcessor(); // Reject pending requests so ping handler can exit quickly requestManager.RejectAllWithCancellation(); @@ -1175,6 +1440,7 @@ public async Task DisconnectAndWaitAsync(TimeSpan timeout, CancellationToken can ClearReconnectState(); // Clear all reconnect state on user disconnect StopPingTimerSync(); + StopMessageProcessor(); // Reject pending requests so ping handler can exit quickly requestManager.RejectAllWithCancellation(); @@ -1742,7 +2008,7 @@ private void CheckIfNotConnected() ? null : new AdminCredentials(config.AdminUser, config.AdminPassword); - public async Task> Request( + public async Task>> Request( Dictionary request, TimeSpan? timeout = null, RequestFailurePolicy? policyOverride = null, @@ -1760,10 +2026,11 @@ public async Task> Request( requestManager.Reject(_request.Id, error); } - return await _request.Promise; + object resolved = await _request.Promise; + return XrplResponse.From>(resolved); } - public async Task GRequest( + public async Task> GRequest( R request, TimeSpan? timeout = null, RequestFailurePolicy? policyOverride = null, @@ -1781,7 +2048,8 @@ public async Task GRequest( requestManager.Reject(_request.Id, error); } - return await _request.Promise; + object resolved = await _request.Promise; + return XrplResponse.From(resolved); } public string GetUrl() => url; @@ -1794,11 +2062,17 @@ private async Task OnceOpen(WebSocketClient connectedSocket, long sessionId) { // Check if this callback is from the active session (not retiring) bool isActiveSession; + ConnectionSession? openedSession = null; lock (_sessionLock) { isActiveSession = _activeSession != null && _activeSession.SessionId == sessionId && !_activeSession.IsRetiring; + + if (isActiveSession) + { + openedSession = _activeSession; + } } if (!isActiveSession) // Callback from a retired session - ignore silently @@ -1841,6 +2115,19 @@ private async Task OnceOpen(WebSocketClient connectedSocket, long sessionId) connectedSocket.ResetIntentionalDisconnect(); + // The session has a socket that connected, and only from here can it hold subscriptions - + // which is what makes its end worth announcing. Sessions are created before the connect + // attempt, so without this a server that is down would announce one ended session per + // retry, each of them a connection that never was. + openedSession.MarkAsOpened(); + + // Before ResolveAllAwaiting and before the OnConnected callback, because both hand control + // to consumer code that subscribes - and the node can answer that subscription while the + // handler is still running. Frames arriving with no channel take the fallback: outside the + // capacity, uncounted by DroppedStreamMessages and dispatched concurrently, so the first + // events after connecting were exactly the ones that could arrive out of order. + StartMessageProcessor(); + try { connectionManager.ResolveAllAwaiting(); @@ -1862,9 +2149,6 @@ private async Task OnceOpen(WebSocketClient connectedSocket, long sessionId) // Start ping timer AFTER connection is fully established and all callbacks completed // This is outside try/catch to ensure it always runs on successful connection StartPingTimer(); - - // Start background message processor for stream messages - StartMessageProcessor(); } /// @@ -1921,6 +2205,18 @@ await errorHandler $"OnConnected handler failed {failures} time(s) in a row: {error.Message}. Giving up after {config.MaxReconnectAttempts} attempts. Call Connect() to retry.", ConnectionCloseSeverity.Error); + // Rejected here, before Disconnect(), and with the reason that is actually true. The + // requests in flight are being stopped because this client gave up connecting, not + // because anyone cancelled them - and Disconnect() rejects with cancellation, which is + // right for a close the caller asked for and wrong for a failure. Connect() is where + // that difference shows: it is two operations, the connection and the server_info that + // SetNetworkId sends straight after, and the socket really does open for a moment + // before a failing handler brings it down. A caller that got as far as the second + // operation was told its own request had been cancelled, having cancelled nothing. + requestManager.RejectAll(new NotConnectedException( + $"Gave up connecting to {url}: the OnConnected handler failed {failures} time(s) in a row. " + + $"Call Connect() to retry.")); + await Disconnect(); return; } @@ -1932,6 +2228,7 @@ await errorHandler reconnect: BuildReconnectInfo(failures)); StopPingTimerSync(); + StopMessageProcessor(); requestManager.RejectAllWithCancellation(); await WaitForPingToFinishAsync(); @@ -2012,12 +2309,17 @@ private async Task OnceClose(int? code, string? description, WebSocketClient clo // Check if this callback is from a retiring session using session ID bool isActiveSession; var isRetiringSession = false; + + // The session object itself, not just the verdict about it: the end-of-session + // announcement below is guarded per session, and only the object carries that guard. + ConnectionSession? closingSession = null; lock (_sessionLock) { if (_activeSession != null) { if (_activeSession.SessionId == sessionId) { + closingSession = _activeSession; // Same session - but check if it's marked as retiring isActiveSession = !_activeSession.IsRetiring; isRetiringSession = _activeSession.IsRetiring; @@ -2064,8 +2366,10 @@ private async Task OnceClose(int? code, string? description, WebSocketClient clo return; } - // Only stop ping timer for current socket + // Only for the current socket - and the message processor goes with it, this connection + // is over. StopPingTimerSync(); + StopMessageProcessor(); // Check if this is a network drop (FailureReason set by WebSocketClient) var isNetworkDrop = closingSocket.FailureReason == SocketFailureReason.NetworkDrop; @@ -2110,6 +2414,16 @@ private async Task OnceClose(int? code, string? description, WebSocketClient clo } } + // The socket that carried this session has closed for real, so the session is over too. + // OnDisconnect above says a socket closed; this says what the consumer actually has to act + // on - that the subscriptions held against it are gone. Raised here as well as on the two + // deliberate retirement paths so that one subscription is enough to cover every way a + // session can end. + await NotifySessionEndedAsync( + closingSession, + intentionalDisconnect ? SessionEndReason.UserDisconnected : SessionEndReason.ConnectionLost, + userMessage); + if (intentionalDisconnect) { _reconnectAttempts = 0; @@ -2697,6 +3011,29 @@ private void StartPingTimer() } } + /// + /// Stops the ping timer, and nothing else. + /// + /// + /// It used to stop the message processor too, which tied two unrelated lifecycles together: + /// begins by calling this, so starting the processor before the + /// ping timer had it torn down again moments later - and that is what forced + /// to the very end of OnceOpen, after the + /// OnConnected callback, leaving every frame answered during that callback to the + /// fallback path. The processor is now stopped explicitly wherever a connection genuinely + /// ends: , , OnceClose, + /// OnConnectHandlerFailedAsync, and + /// RetireCurrentSessionAndReconnectAsync - the same six places the side effect used to + /// fire, so when the processor stops is unchanged; only the spurious stop inside + /// is gone. + /// + /// ReconnectLoopAsync retires a session without stopping the processor, and deliberately + /// so: it did not stop it before this change either, OnceClose has already run by the + /// time it retries, and tears down any leftover when the + /// next connection opens. Adding a stop there would discard frames still queued from before the + /// drop, on a path where nothing shows that is wanted. + /// + /// private void StopPingTimerSync() { var cts = _pingCts; @@ -2719,8 +3056,6 @@ private void StopPingTimerSync() wasmTimer?.Dispose(); cts?.Dispose(); - - StopMessageProcessor(); } /// @@ -2911,17 +3246,22 @@ private void StartMessageProcessor() // Create new session-bound channel and CTS // Using bounded channel to prevent memory issues under high load - _streamMessageChannel = System.Threading.Channels.Channel.CreateBounded(new BoundedChannelOptions(10000) - { - SingleReader = true, - SingleWriter = false, - FullMode = BoundedChannelFullMode.DropOldest - }); + // itemDropped runs inside TryWrite, i.e. on the receive loop, so it does no more than + // increment: raising an event or logging here would put consumer code back on the path + // this channel exists to keep it off. Callers read DroppedStreamMessages instead. + _streamMessageChannel = System.Threading.Channels.Channel.CreateBounded( + new BoundedChannelOptions(Math.Max(1, config?.StreamMessageQueueCapacity ?? 10000)) + { + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.DropOldest + }, + itemDropped: _ => Interlocked.Increment(ref _droppedStreamMessages)); _messageProcessorCts = new CancellationTokenSource(); - + var channel = _streamMessageChannel; var cts = _messageProcessorCts; - + // Use truly async reader - works correctly in WebAssembly single-threaded environment _messageProcessorTask = Task.Run(async () => { @@ -2930,18 +3270,18 @@ private void StartMessageProcessor() var reader = channel.Reader; while (await reader.WaitToReadAsync(cts.Token).ConfigureAwait(false)) { - while (reader.TryRead(out var message)) + while (reader.TryRead(out SessionFrame item)) { if (cts.Token.IsCancellationRequested) return; try { - await ProcessStreamMessageAsync(message).ConfigureAwait(false); + await ProcessSessionFrameAsync(item).ConfigureAwait(false); } catch (Exception ex) { - await NotifyStreamProcessingErrorAsync(ex, message).ConfigureAwait(false); + await NotifyStreamProcessingErrorAsync(ex, item.Frame).ConfigureAwait(false); } } } @@ -3009,32 +3349,49 @@ private void StopMessageProcessorInternal() /// Processes a single stream message (transaction, ledger, etc.) in the background. /// This is the async version of stream handling, decoupled from the receive loop. /// - private async Task ProcessStreamMessageAsync(string message) + /// + /// Takes the frame rather than text for the same reason the response path does: a stream + /// message is not wrapped in a "result" envelope, so the frame IS the event, and each typed + /// event pairs itself with it through - the same + /// mechanism uses for + /// - so a consumer's is the exact bytes rippled sent, not a + /// re-encode of a string that was itself decoded from them. Text is materialized only for + /// //, which predate + /// this change and still take a string, and only when something is listening. + /// + private async Task ProcessStreamMessageAsync(byte[] frame) { lastActivityTime = DateTime.UtcNow; + // Lazily materialized, and shared by every caller below: rippled can attach both warnings + // to the same message, and a null frame - OnMessage(null), routed rather than raised at + // the entry point - must not throw again here, out of the very report that is supposed to + // surface it. + string text = null; + string Text() => text ??= (frame is null ? null : Encoding.UTF8.GetString(frame)); + BaseResponse data; try { - data = JsonSerializer.Deserialize(message, XrplJsonOptions.Default); + data = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); } catch (Exception error) { if (OnError is not null) { - await OnError?.Invoke(error: "error", errorMessage: "badMessage", error.Message, message)!; + await OnError?.Invoke(error: "error", errorMessage: "badMessage", error.Message, Text())!; } return; } if (data.Warning != null && OnWarning is not null) { - await OnWarning.Invoke(data.Warning, message); + await OnWarning.Invoke(data.Warning, Text()); } if (data.Warnings is { Count: > 0, } && OnServerWarning is not null) { - await OnServerWarning.Invoke(data.Warnings, message); + await OnServerWarning.Invoke(data.Warnings, Text()); } // Process stream messages by type @@ -3045,7 +3402,8 @@ private async Task ProcessStreamMessageAsync(string message) { case ResponseStreamType.ledgerClosed: { - var response = JsonSerializer.Deserialize(message, XrplJsonOptions.Default); + var response = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); + response.AttachFrame(frame); if (OnLedgerClosed is not null) { await OnLedgerClosed.Invoke(response)!; @@ -3055,7 +3413,8 @@ private async Task ProcessStreamMessageAsync(string message) case ResponseStreamType.validationReceived: { - var response = JsonSerializer.Deserialize(message, XrplJsonOptions.Default); + var response = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); + response.AttachFrame(frame); if (OnValidationReceived is not null) { await OnValidationReceived.Invoke(response)!; @@ -3065,7 +3424,8 @@ private async Task ProcessStreamMessageAsync(string message) case ResponseStreamType.transaction: { - var response = JsonSerializer.Deserialize(message, XrplJsonOptions.Default); + var response = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); + response.AttachFrame(frame); if (OnTransaction is not null) { await OnTransaction.Invoke(response)!; @@ -3075,7 +3435,8 @@ private async Task ProcessStreamMessageAsync(string message) case ResponseStreamType.peerStatusChange: { - var response = JsonSerializer.Deserialize(message, XrplJsonOptions.Default); + var response = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); + response.AttachFrame(frame); if (OnPeerStatusChange is not null) { await OnPeerStatusChange.Invoke(response)!; @@ -3085,7 +3446,8 @@ private async Task ProcessStreamMessageAsync(string message) case ResponseStreamType.consensusPhase: { - var response = JsonSerializer.Deserialize(message, XrplJsonOptions.Default); + var response = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); + response.AttachFrame(frame); if (OnConsensusPhase is not null) { await OnConsensusPhase.Invoke(response)!; @@ -3095,7 +3457,8 @@ private async Task ProcessStreamMessageAsync(string message) case ResponseStreamType.path_find: { - var response = JsonSerializer.Deserialize(message, XrplJsonOptions.Default); + var response = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); + response.AttachFrame(frame); if (OnPathFind is not null) { await OnPathFind.Invoke(response)!; @@ -3105,7 +3468,8 @@ private async Task ProcessStreamMessageAsync(string message) case ResponseStreamType.manifestReceived: { - var response = JsonSerializer.Deserialize(message, XrplJsonOptions.Default); + var response = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); + response.AttachFrame(frame); if (OnManifestReceived is not null) { await OnManifestReceived.Invoke(response)!; @@ -3115,7 +3479,8 @@ private async Task ProcessStreamMessageAsync(string message) case ResponseStreamType.bookChanges: { - var response = JsonSerializer.Deserialize(message, XrplJsonOptions.Default); + var response = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); + response.AttachFrame(frame); if (OnBookChanges is not null) { await OnBookChanges.Invoke(response)!; @@ -3125,7 +3490,8 @@ private async Task ProcessStreamMessageAsync(string message) case ResponseStreamType.serverStatus: { - var response = JsonSerializer.Deserialize(message, XrplJsonOptions.Default); + var response = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); + response.AttachFrame(frame); if (OnServerStatus is not null) { await OnServerStatus.Invoke(response)!; @@ -3135,7 +3501,8 @@ private async Task ProcessStreamMessageAsync(string message) case ResponseStreamType.error: { - var response = JsonSerializer.Deserialize(message, XrplJsonOptions.Default); + var response = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); + response.AttachFrame(frame); if (OnError is not null) { await OnError.Invoke(response.Error, response.ErrorMessage, response.ErrorCode?.ToString(), response); @@ -3163,16 +3530,31 @@ private async Task ProcessStreamMessageAsync(string message) /// private Task IOnMessageFastPath(string message) { - return IOnMessageFastPath(message, null); + return IOnMessageFastPath(message, null, sessionId: 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. + /// for why the bytes are kept as they are. /// - private Task IOnMessageFastPath(byte[] utf8Message) + /// + /// Internal rather than private so a test can drive the actual production entry point - the + /// one calls with the frame the socket produced - + /// instead of only , where Frame() always synthesizes a + /// fresh byte array from the string rather than reusing one. InternalsVisibleTo to + /// Xrpl.Tests is already declared in the project file for this reason. + /// + internal Task IOnMessageFastPath(byte[] utf8Message) { - return IOnMessageFastPath(null, utf8Message); + return IOnMessageFastPath(null, utf8Message, sessionId: null); + } + + /// + /// As above, for a frame whose originating session is known. + /// + internal Task IOnMessageFastPath(byte[] utf8Message, long? sessionId) + { + return IOnMessageFastPath(null, utf8Message, sessionId); } /// @@ -3188,10 +3570,17 @@ private Task IOnMessageFastPath(byte[] utf8Message) /// /// 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. + /// common case. Stream messages are routed on through Frame(), which likewise reuses + /// when present rather than encoding a fresh copy of + /// - the frame stream events pair themselves with is exactly the + /// bytes the socket produced. Only the warning and error callbacks, which still take a string, + /// ask for text at all, through Text(), and materialize it once and only then. /// - private async Task IOnMessageFastPath(string message, byte[] utf8Message) + /// + /// The session whose socket produced this frame, or when the caller has + /// no session to name - , which anyone may call directly. + /// + private async Task IOnMessageFastPath(string message, byte[] utf8Message, long? sessionId) { lastActivityTime = DateTime.UtcNow; @@ -3207,6 +3596,16 @@ string Text() return message; } + // The stream path now runs on the frame, not on text: encodes only when the binary + // callback did not already hand one over, mirroring RequestManager.HandleResponse(string)'s + // own Encoding.UTF8.GetBytes fallback for the same reason - so OnMessage(string), still a + // public entry point, keeps working without a frame of its own to reuse. A null message + // stays null rather than throwing out of Encoding.UTF8.GetBytes here: OnMessage(null) used + // to travel down to the stream processor and be reported through OnError as a bad message + // rather than raised at the entry point, and that must keep being true now that the + // pipeline carries bytes instead of text. + byte[] Frame() => utf8Message ?? (message is null ? null : Encoding.UTF8.GetBytes(message)); + // Scan message for "id" property to detect response messages var isResponse = utf8Message is null ? IsLikelyResponse(message) : IsLikelyResponse(utf8Message); @@ -3262,7 +3661,7 @@ string Text() { // Message has "id" but no matching pending request — this is an async // follow-up (e.g. path_find updates). Route to stream processing. - EnqueueStreamMessage(Text()); + EnqueueStreamMessage(Frame(), sessionId); return; } @@ -3297,51 +3696,186 @@ string Text() { // This is a stream message (no "id") - process asynchronously // to avoid blocking the receive loop and causing ping timeouts - EnqueueStreamMessage(Text()); + EnqueueStreamMessage(Frame(), sessionId); + } + } + + /// + /// A stream frame together with the session whose socket produced it. + /// + /// + /// The session has to ride along in the queue, not just be checked on the way in: the channel + /// is rebuilt per session by , and between the check and + /// the write nothing holds the two together. Carrying the id lets the reader ask the question + /// again at the only moment that decides anything - just before handlers run. + /// + /// The raw UTF-8 frame. + /// + /// The session whose socket produced it, or when the caller had none to + /// name. + /// + private readonly record struct SessionFrame(byte[] Frame, long? SessionId); + + /// + /// Whether a frame carrying this session id belongs to the connection as it stands right now. + /// + /// + /// Matching the id is not enough: ChangeServer and the reconnect loop call + /// MarkAsRetiring() on the session while it is still _activeSession, and only + /// ConnectInternalAsync installs its replacement. Frames arriving in that window carry + /// the id of the very session being retired, so the retiring flag is part of the test - as + /// OnceOpen and the other lifecycle guards do it, and under the same lock, since + /// IsRetiring is a plain bool published only by _sessionLock. + /// + /// A id means the caller cannot name a session + /// (, which anyone may call): nothing to compare, so nothing is + /// rejected. + /// + /// + private bool IsFromLiveSession(long? sessionId) + { + if (sessionId is null) + { + return true; + } + + lock (_sessionLock) + { + return _activeSession != null && + _activeSession.SessionId == sessionId && + !_activeSession.IsRetiring; } } /// - /// Routes a message to stream processing (Channel or fire-and-forget).
- /// Used for both regular stream messages (no "id") and async follow-ups - /// that have "id" but no matching pending request (e.g. path_find updates). + /// Hands a stream message to the background processor. ///
- private void EnqueueStreamMessage(string message) + /// + /// Used for ordinary stream messages (no id) and for follow-ups carrying an id + /// that matches no pending request, such as path_find updates. + /// + /// Browsers used to take a separate path here - one fire-and-forget task per frame, bypassing + /// the queue entirely, so did not + /// apply, stayed at zero however far handlers fell behind, + /// the backlog was bounded by nothing, and concurrent dispatch could hand handlers events out + /// of the order the node sent them. The queue was built for this environment in the first + /// place ("true async support in WebAssembly single-threaded environment" on + /// ), and measurement confirmed it works there: running the + /// Blazor demo against mainnet, the queue delivered 1 004 transactions over 52 s (19.2 tx/s, + /// 13 ledgers) with no console errors and timestamps in order - against 462 over 33 s + /// (13.9 tx/s) on the bypass. So the platforms no longer diverge: capacity, eviction counting + /// and single-reader ordering hold on every target. + /// + /// + /// The startup window is closed too: used to run at the + /// very end of OnceOpen, after the OnConnected callback, so a handler + /// subscribing there saw its first frames answered before the channel existed and they took + /// the fallback below - outside the capacity, the eviction count and the ordering. It now runs + /// before the callback. That was not a one-line change: begins + /// with StopPingTimerSync, which used to stop the message processor as well, so an + /// earlier start was torn down again moments later; see the remarks on + /// . + /// + /// + /// The fallback stays, because it is still reachable: 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. + /// + /// + private void EnqueueStreamMessage(byte[] frame, long? sessionId = null) { - if (OperatingSystem.IsBrowser()) + // A retiring socket keeps delivering while InitiateGracefulCloseAsync completes, and that + // close runs fire-and-forget alongside the new connection. Without this its last frames + // reach handlers as if they were current - stale after a reconnect, and from an entirely + // different chain after a ChangeServer between networks. + // + // Checking here only saves a queue slot. It cannot be the guarantee: the channel is + // rebuilt per session and the swap is under _messageProcessorLock, not _sessionLock, so + // between this answer and the write below the session can retire and its replacement + // install a new channel - and the frame would land in that one. The answer that counts is + // the one the reader asks in ProcessSessionFrameAsync, immediately before handlers run. + if (!IsFromLiveSession(sessionId)) + { + Interlocked.Increment(ref _staleSessionFramesDropped); + return; + } + + SessionFrame item = new SessionFrame(frame, sessionId); + + // The channel is bounded with DropOldest, so a full queue is not a refusal: TryWrite + // evicts the oldest frame, counts it through itemDropped and reports success. It + // refuses only a completed writer - and that happens on the ordinary path, not just in + // some corner: StopMessageProcessorInternal completes the writer after clearing + // _streamMessageChannel, so a reader that got the reference an instant earlier writes + // into a channel that is already closed. StartPingTimer tears the processor down and + // StartMessageProcessor builds it again on every connect, so the window recurs. + // + // A refused frame therefore goes down the fallback rather than disappearing. It still + // faces the session check there - fallback and queue meet in ProcessSessionFrameAsync. + Channel? channel = _streamMessageChannel; + if (channel?.Writer.TryWrite(item) == true) { - _ = ProcessStreamMessageFireAndForgetAsync(message); + return; } - else + + Interlocked.Increment(ref _fallbackDispatchedStreamMessages); + _ = ProcessStreamMessageFireAndForgetAsync(item); + } + + /// + /// Runs a queued frame through the stream handlers, unless its session stopped being the live + /// one while it waited. + /// + /// + /// This is where the session check has to be final. A frame can be queued while its session is + /// live and dequeued after ChangeServer has moved the client to another network + /// entirely - and the queue holds up to + /// frames, so the wait is not + /// necessarily short. Asking again here costs one uncontended lock per frame, against a JSON + /// parse and a handler call. + /// + private Task ProcessSessionFrameAsync(SessionFrame item) + { + if (!IsFromLiveSession(item.SessionId)) { - var channel = _streamMessageChannel; - if (channel != null) - { - if (!channel.Writer.TryWrite(message)) - { - Debug.WriteLine($"{DateTime.Now}Warning: Stream message channel full, message dropped"); - } - } - else - { - _ = ProcessStreamMessageFireAndForgetAsync(message); - } + Interlocked.Increment(ref _staleSessionFramesDropped); + return Task.CompletedTask; } + + return ProcessStreamMessageAsync(item.Frame); } - + /// - /// Fire-and-forget stream message processing for single-threaded environments like WebAssembly. - /// Uses ConfigureAwait(false) to prevent deadlocks and allow proper continuation scheduling. + /// Processes a stream frame outside the queue, on its own task. /// - private async Task ProcessStreamMessageFireAndForgetAsync(string message) + /// + /// Three ways in, none of them platform-specific since browsers stopped taking a path of their + /// own: before has run, after the processor was stopped, + /// and when the channel refuses the write because its writer is already completed. Ordering + /// and the capacity bound do not hold here - that is the cost of not losing the frame. + /// ConfigureAwait(false) throughout, to keep continuations off a captured context. + /// + /// The yield is what makes "fire and forget" true. Without it an async method runs on the + /// caller's thread up to its first real await, and the first real await inside + /// comes after + /// JsonSerializer.Deserialize - so the receive loop would pay for parsing every frame + /// that takes this path, plus whatever a handler does before its own first await. That is + /// precisely the head-of-line blocking the queue exists to prevent, and the fallback would + /// have reintroduced it for the startup window, for a stopped processor and for a refused + /// write. + /// + /// + private async Task ProcessStreamMessageFireAndForgetAsync(SessionFrame item) { + await Task.Yield(); + try { - await ProcessStreamMessageAsync(message).ConfigureAwait(false); + await ProcessSessionFrameAsync(item).ConfigureAwait(false); } catch (Exception ex) { - await NotifyStreamProcessingErrorAsync(ex, message).ConfigureAwait(false); + await NotifyStreamProcessingErrorAsync(ex, item.Frame).ConfigureAwait(false); } } @@ -3352,7 +3886,7 @@ private async Task ProcessStreamMessageFireAndForgetAsync(string message) /// bugs are observable. The message loop is always kept alive: cancellation is ignored, and an /// exception thrown by the handler itself is contained. ///
- private async Task NotifyStreamProcessingErrorAsync(Exception ex, string message) + private async Task NotifyStreamProcessingErrorAsync(Exception ex, byte[] frame) { Debug.WriteLine($"{DateTime.Now}Stream message processing error: {ex.Message}"); @@ -3369,7 +3903,12 @@ private async Task NotifyStreamProcessingErrorAsync(Exception ex, string message try { - await handler.Invoke(error: "error", errorMessage: "streamHandlerError", message: ex.Message, data: message).ConfigureAwait(false); + // Materialized only here, on the failure path: OnError's data parameter predates the + // frame and still takes text, and nothing before this point needed a string at all. + // Guarded against a null frame - OnMessage(null) reaches this path too - so the report + // itself cannot throw and swallow the very failure it exists to surface. + string text = frame is null ? null : Encoding.UTF8.GetString(frame); + await handler.Invoke(error: "error", errorMessage: "streamHandlerError", message: ex.Message, data: text).ConfigureAwait(false); } catch (Exception notifyEx) { diff --git a/Xrpl/Models/Common/Currency.cs b/Xrpl/Models/Common/Currency.cs index e00ec478..2ddc5d1f 100644 --- a/Xrpl/Models/Common/Currency.cs +++ b/Xrpl/Models/Common/Currency.cs @@ -291,9 +291,17 @@ public static bool IsLpToken(this Currency currency) return currency.CurrencyCode.IsLpToken(); } + /// + /// Whether this amount is a multi-purpose token - that is, whether it carries an issuance id. + /// + /// + /// An issuance id is what distinguishes a multi-purpose token from every other kind of amount, + /// so this is exclusive with and with an issued currency: an amount is at + /// most one of the three. + /// public static bool IsMPTToken(this Currency currency) { - return string.IsNullOrWhiteSpace(currency.MPTokenIssuanceID); + return currency is not null && !string.IsNullOrWhiteSpace(currency.MPTokenIssuanceID); } public static bool IsLpToken(this TrustLine currency) { diff --git a/Xrpl/Models/Methods/Path.cs b/Xrpl/Models/Common/PathStep.cs similarity index 62% rename from Xrpl/Models/Methods/Path.cs rename to Xrpl/Models/Common/PathStep.cs index bd8915a1..52ac4473 100644 --- a/Xrpl/Models/Methods/Path.cs +++ b/Xrpl/Models/Common/PathStep.cs @@ -2,14 +2,33 @@ using Xrpl.Models.Enums; //https://github.com/XRPLF/xrpl.js/blob/b20c05c3680d80344006d20c44b4ae1c3b0ffcac/packages/xrpl/src/models/common/index.ts#L62 //https://xrpl.org/paths.html#path-steps -namespace Xrpl.Models.Methods +namespace Xrpl.Models.Common { /// - /// A path set is an array.
- /// Each member of the path set is another array that represents an individual path.
- /// Each member of a path is an object that specifies the step. + /// One step of one path: where the payment goes next, not the whole route. ///
- public class Path //todo rename to path steps? + /// + /// A path set is an array of paths, and a path is an array of these. The nesting reads + /// correctly now that the name does: List<List<PathStep>> is a list of paths, + /// where the old List<List<Path>> read as a list of lists of paths. + /// + /// Named Path until 11.0.0.0, which collided with - any file + /// with using Xrpl.Models.Methods; and implicit usings on could not write + /// Path.Combine - and with Xrpl.BinaryCodec.Types.Path, which is a whole path + /// rather than a step, so the same name meant a container in one half of the SDK and its + /// element in the other. Everything around it already said step: , + /// Validation.IsPathStep, and xrpl.js, where this is PathStep. + /// + /// + // No unknown-field capture here on purpose: a Path step is not only read off a + // ripple_path_find/path_find response, it is fed straight back into an outgoing + // Payment (Transactions/Payment.cs Paths) and PathFindCreateRequest. Capturing + // unknown members would let a field read from one node's response ride back out + // inside a transaction the user never put it in - and worse, StObject.FromJson + // passes signingOnly only to the top level, so a nested unknown member reaches the + // displayed tx_json but not the signed blob. Show-one-sign-another, the exact + // failure this branch exists to remove, arriving from the outgoing side. + public class PathStep { /// /// (Optional) If present, this path step represents rippling through the specified address.
diff --git a/Xrpl/Models/Ledger/BaseLedgerEntry.cs b/Xrpl/Models/Ledger/BaseLedgerEntry.cs index 798db899..518c024f 100644 --- a/Xrpl/Models/Ledger/BaseLedgerEntry.cs +++ b/Xrpl/Models/Ledger/BaseLedgerEntry.cs @@ -1,4 +1,6 @@ -using System.Text.Json.Serialization; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; using Xrpl.Client.Json.Converters; //https://github.com/XRPLF/xrpl.js/blob/76b73e16a97e1a371261b462ee1a24f1c01dbb0c/packages/xrpl/src/models/ledger/BaseLedgerEntry.ts @@ -8,8 +10,9 @@ namespace Xrpl.Models.Ledger public class BaseLedgerEntry { + // Nullable: this model also represents PreviousFields/FinalFields content, where the node omits LedgerEntryType. [JsonConverter(typeof(LedgerEntryTypeConverter))] - public LedgerEntryType LedgerEntryType { get; set; } + public LedgerEntryType? LedgerEntryType { get; set; } /// /// The unique ID for this ledger entry.
@@ -20,5 +23,23 @@ public class BaseLedgerEntry public string Index { get; set; } [JsonPropertyName("LedgerIndex")] public string LedgerIndex { get; set; } + + /// + /// Members of this ledger entry that no declared property claims — new fields an amendment + /// added to the wire format before this SDK modeled them, or anything else unrecognized. + /// Populated on every derived LO* type, and on FinalFields/PreviousFields/NewFields reached + /// through and the node converters, because + /// those converters only pick the concrete .NET type; the field-level read is the ordinary + /// reflection-based deserializer, which is what honors this attribute. + /// + /// + /// This is not a substitute for XrplResponse<T>.Raw: values here have already + /// gone through JSON parsing (numbers, strings, nested objects as ), + /// while Raw is the exact bytes the node sent. Use Raw when byte-for-byte + /// fidelity matters (verifying what a node actually said); use this when a caller just needs + /// to read a field the model does not yet declare. + /// + [JsonExtensionData] + public Dictionary UnknownFields { get; set; } } } \ No newline at end of file diff --git a/Xrpl/Models/Ledger/BaseLedgerInfo.cs b/Xrpl/Models/Ledger/BaseLedgerInfo.cs index 099fed30..014b878e 100644 --- a/Xrpl/Models/Ledger/BaseLedgerInfo.cs +++ b/Xrpl/Models/Ledger/BaseLedgerInfo.cs @@ -1,4 +1,6 @@ -using System.Text.Json.Serialization; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; //https://github.com/XRPLF/xrpl.js/blob/76b73e16a97e1a371261b462ee1a24f1c01dbb0c/packages/xrpl/src/models/ledger/Ledger.ts @@ -7,6 +9,16 @@ namespace Xrpl.Models.Ledger /// base ledger fields public class LOBaseLedger { + /// + /// Members the node sent that no declared property here claims. Mirrors + /// for ledger entries and + /// for command results: + /// without it, anything this model does not yet know about is dropped between the node and + /// the caller instead of surviving the round trip. + /// + [JsonExtensionData] + public Dictionary UnknownFields { get; set; } + /// /// The SHA-512Half of this ledger version.
/// This serves as a unique identifier for this ledger and all its contents. @@ -18,7 +30,15 @@ public class LOBaseLedger /// The ledger index of the ledger.
/// Some API methods display this as a quoted integer; some display it as a native JSON number. ///
- [JsonPropertyName("ledger_index")] - public uint LedgerIndex { get; set; } + /// + /// Unconditional for the dedicated ledger_closed command (rippled + /// LedgerClosed.cpp), but this class is also the base of for the + /// general ledger command, where the shared lookupLedger helper + /// (RPCLedgerHelpers.cpp) sets this field only when the resolved ledger is closed — + /// an open/current ledger response carries ledger_current_index instead and omits + /// this member entirely. + /// + [JsonPropertyName("ledger_index")] + public uint? LedgerIndex { get; set; } } } diff --git a/Xrpl/Models/Ledger/HashOrTransaction.cs b/Xrpl/Models/Ledger/HashOrTransaction.cs index fa2792c9..9d640551 100644 --- a/Xrpl/Models/Ledger/HashOrTransaction.cs +++ b/Xrpl/Models/Ledger/HashOrTransaction.cs @@ -1,4 +1,6 @@ -using System.Text.Json.Serialization; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; using System; @@ -22,6 +24,16 @@ public class HashOrTransaction public class LedgerTransaction { + /// + /// Members the node sent that no declared property here claims. Mirrors + /// for ledger entries and + /// for command results: + /// without it, anything this model does not yet know about is dropped between the node and + /// the caller instead of surviving the round trip. + /// + [JsonExtensionData] + public Dictionary UnknownFields { get; set; } + /// /// The ledger close time represented in ISO 8601 time format. /// @@ -63,8 +75,13 @@ public class LedgerTransaction /// Whether or not the transaction is included in a validated ledger.
/// Any transaction not yet in a validated ledger is subject to change. ///
+ /// + /// Structurally absent for API version <= 1: rippled's LedgerToJson.cpp only adds this + /// member inside the apiVersion > 1 branch of its per-transaction JSON; the legacy + /// v1 branch copies the flat transaction JSON and never writes it at all. + /// [JsonPropertyName("validated")] - public bool Validated { get; set; } + public bool? Validated { get; set; } } } diff --git a/Xrpl/Models/Ledger/LOAccountRoot.cs b/Xrpl/Models/Ledger/LOAccountRoot.cs index 7d05c6a4..0b1e7f8c 100644 --- a/Xrpl/Models/Ledger/LOAccountRoot.cs +++ b/Xrpl/Models/Ledger/LOAccountRoot.cs @@ -90,10 +90,6 @@ public enum AccountRootFlags : uint ///
public class LOAccountRoot : BaseLedgerEntry { - public LOAccountRoot() - { - LedgerEntryType = LedgerEntryType.AccountRoot; - } /// /// Identifier of the associated AMM (Automated Market Maker) instance. /// @@ -131,11 +127,11 @@ public LOAccountRoot() /// /// A bit-map of boolean flags enabled for this account. /// - public AccountRootFlags Flags { get; set; } + public AccountRootFlags? Flags { get; set; } /// /// The sequence number of the next valid transaction for this account. /// - public uint Sequence { get; set; } + public uint? Sequence { get; set; } /// /// The account's current XRP balance in drops, represented as a string. /// @@ -144,7 +140,7 @@ public LOAccountRoot() /// /// The number of objects this account owns in the ledger, which contributes to its owner reserve. /// - public uint OwnerCount { get; set; } + public uint? OwnerCount { get; set; } /// /// The identifying hash of the transaction that most recently modified this object. /// @@ -152,7 +148,7 @@ public LOAccountRoot() /// /// The index of the ledger that contains the transaction that most recently modified this object. /// - public uint PreviousTxnLgrSeq { get; set; } + public uint? PreviousTxnLgrSeq { get; set; } /// /// The identifying hash of the transaction most recently sent by this account.
/// This field must be enabled to use the AccountTxnID transaction field.
diff --git a/Xrpl/Models/Ledger/LOAmendments.cs b/Xrpl/Models/Ledger/LOAmendments.cs index 7ee5ad8e..6acf3780 100644 --- a/Xrpl/Models/Ledger/LOAmendments.cs +++ b/Xrpl/Models/Ledger/LOAmendments.cs @@ -24,10 +24,6 @@ public enum EnableAmendmentFlags ///
public class LOAmendments : BaseLedgerEntry { - public LOAmendments() - { - LedgerEntryType = LedgerEntryType.Amendments; - } /// /// Array of objects describing the status of amendments that have majority support but are not yet enabled.
/// If omitted, there are no pending amendments with majority support. @@ -42,7 +38,7 @@ public LOAmendments() /// A bit-map of boolean flags.
/// No flags are defined for the Amendments object type, so this value is always 0. ///
- public uint Flags { get; set; } + public uint? Flags { get; set; } /// /// The identifying hash of the transaction that most recently modified this object. diff --git a/Xrpl/Models/Ledger/LOAmm.cs b/Xrpl/Models/Ledger/LOAmm.cs index 67d3b284..4c2a5798 100644 --- a/Xrpl/Models/Ledger/LOAmm.cs +++ b/Xrpl/Models/Ledger/LOAmm.cs @@ -1,4 +1,5 @@ -using System; +using System.Text.Json; +using System; using System.Collections.Generic; using System.Text.Json.Serialization; @@ -10,10 +11,6 @@ namespace Xrpl.Models.Ledger { public class LOAmm : BaseLedgerEntry { - public LOAmm() - { - LedgerEntryType = LedgerEntryType.AMM; - } /// /// The special account that holds the AMM's assets and issues its LPTokens. /// Serialized as Account, which is the name rippled gives this field. @@ -50,7 +47,7 @@ public LOAmm() /// between 0% and 1%.
/// This field is required. ///
- public uint TradingFee { get; set; } + public uint? TradingFee { get; set; } /// /// A list of vote objects, representing votes on the pool's trading fee.. /// @@ -79,6 +76,12 @@ public interface IAuthAccount } public class AuthAccount : IAuthAccount { + // No unknown-field capture here on purpose: this shape is also part of an outgoing + // transaction (AMMBid), and a member captured off a node response would + // ride back out inside a transaction the user never put it in. StObject.FromJson + // passes signingOnly only to the top level, so such a member would reach the + // displayed tx_json but not the signed blob. + [JsonPropertyName("account")] public string Account { get; set; } } @@ -91,6 +94,16 @@ public interface IVoteEntry } public class VoteEntry : IVoteEntry { + /// + /// Members the node sent that no declared property here claims. Mirrors + /// for ledger entries and + /// for command results: + /// without it, anything this model does not yet know about is dropped between the node and + /// the caller instead of surviving the round trip. + /// + [JsonExtensionData] + public Dictionary UnknownFields { get; set; } + [JsonPropertyName("account")] public string Account { get; set; } [JsonPropertyName("trading_fee")] @@ -103,6 +116,16 @@ public class VoteEntry : IVoteEntry ///
public class AuctionSlot { + /// + /// Members the node sent that no declared property here claims. Mirrors + /// for ledger entries and + /// for command results: + /// without it, anything this model does not yet know about is dropped between the node and + /// the caller instead of surviving the round trip. + /// + [JsonExtensionData] + public Dictionary UnknownFields { get; set; } + /// /// The current owner of this auction slot. /// diff --git a/Xrpl/Models/Ledger/LOBridge.cs b/Xrpl/Models/Ledger/LOBridge.cs index 3f7d6f8c..b1b51d1f 100644 --- a/Xrpl/Models/Ledger/LOBridge.cs +++ b/Xrpl/Models/Ledger/LOBridge.cs @@ -11,11 +11,6 @@ namespace Xrpl.Models.Ledger; /// public class LOBridge : BaseLedgerEntry { - public LOBridge() - { - LedgerEntryType = LedgerEntryType.Bridge; - } - /// /// The account that owns this bridge on this chain. /// diff --git a/Xrpl/Models/Ledger/LOCheck.cs b/Xrpl/Models/Ledger/LOCheck.cs index 5d26db70..3741ce2e 100644 --- a/Xrpl/Models/Ledger/LOCheck.cs +++ b/Xrpl/Models/Ledger/LOCheck.cs @@ -16,11 +16,6 @@ namespace Xrpl.Models.Ledger /// public class LOCheck : BaseLedgerEntry, IDestination { - public LOCheck() - { - LedgerEntryType = LedgerEntryType.Check; - } - /// /// The sender of the Check. Cashing the Check debits this address's balance. /// @@ -48,7 +43,7 @@ public LOCheck() /// /// The index of the ledger that contains the transaction that most recently modified this object. /// - public uint PreviousTxnLgrSeq { get; set; } + public uint? PreviousTxnLgrSeq { get; set; } /// /// The maximum amount of currency this Check can debit the sender.
/// If the Check is successfully cashed, @@ -59,7 +54,7 @@ public LOCheck() /// /// The sequence number of the CheckCreate transaction that created this check. /// - public uint Sequence { get; set; } + public uint? Sequence { get; set; } /// /// A hint indicating which page of the destination's owner directory links to this object, /// in case the directory consists of multiple pages. diff --git a/Xrpl/Models/Ledger/LOCredential.cs b/Xrpl/Models/Ledger/LOCredential.cs index 1360741e..365707c5 100644 --- a/Xrpl/Models/Ledger/LOCredential.cs +++ b/Xrpl/Models/Ledger/LOCredential.cs @@ -31,11 +31,6 @@ public enum CredentialFlags : uint /// public class LOCredential : BaseLedgerEntry { - public LOCredential() - { - LedgerEntryType = LedgerEntryType.Credential; - } - /// /// The account that is the subject (holder) of the credential. /// @@ -99,7 +94,7 @@ public string URI /// A bit-map of boolean flags. See . ///
[JsonPropertyName("Flags")] - public new uint Flags { get; set; } + public new uint? Flags { get; set; } /// /// A hint indicating which page of the subject's owner directory links to this entry. @@ -123,6 +118,6 @@ public string URI /// The index of the ledger that contains the transaction that most recently modified this entry. /// [JsonPropertyName("PreviousTxnLgrSeq")] - public uint PreviousTxnLgrSeq { get; set; } + public uint? PreviousTxnLgrSeq { get; set; } } } diff --git a/Xrpl/Models/Ledger/LODID.cs b/Xrpl/Models/Ledger/LODID.cs index 4fd89bb4..380e49e9 100644 --- a/Xrpl/Models/Ledger/LODID.cs +++ b/Xrpl/Models/Ledger/LODID.cs @@ -10,14 +10,6 @@ namespace Xrpl.Models.Ledger /// public class LODID : BaseLedgerEntry { - /// - /// Initializes a new instance of the LODID class. - /// - public LODID() - { - LedgerEntryType = LedgerEntryType.DID; - } - /// /// The account that controls the DID. /// @@ -59,6 +51,6 @@ public LODID() /// The index of the ledger that contains the transaction that most recently modified this entry. /// [JsonPropertyName("PreviousTxnLgrSeq")] - public uint PreviousTxnLgrSeq { get; set; } + public uint? PreviousTxnLgrSeq { get; set; } } } diff --git a/Xrpl/Models/Ledger/LODelegate.cs b/Xrpl/Models/Ledger/LODelegate.cs index 3b32cbdb..1b5c1f38 100644 --- a/Xrpl/Models/Ledger/LODelegate.cs +++ b/Xrpl/Models/Ledger/LODelegate.cs @@ -11,11 +11,6 @@ namespace Xrpl.Models.Ledger; /// public class LODelegate : BaseLedgerEntry { - public LODelegate() - { - LedgerEntryType = LedgerEntryType.Delegate; - } - /// /// The account that granted the permissions. /// diff --git a/Xrpl/Models/Ledger/LODepositPreauth.cs b/Xrpl/Models/Ledger/LODepositPreauth.cs index 5b3d1a56..4c917241 100644 --- a/Xrpl/Models/Ledger/LODepositPreauth.cs +++ b/Xrpl/Models/Ledger/LODepositPreauth.cs @@ -1,6 +1,7 @@ // https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/src/models/ledger/DepositPreauth.ts // https://xrpl.org/docs/references/protocol/ledger-data/ledger-entry-types/depositpreauth +using System.Text.Json; using System.Text.Json.Serialization; using System.Collections.Generic; @@ -17,11 +18,6 @@ namespace Xrpl.Models.Ledger /// public class LODepositPreauth : BaseLedgerEntry { - public LODepositPreauth() - { - LedgerEntryType = LedgerEntryType.DepositPreauth; - } - /// /// The account that granted the preauthorization (the destination of the future payments). /// @@ -61,7 +57,7 @@ public LODepositPreauth() /// /// The index of the ledger that contains the transaction that most recently modified this object. /// - public uint PreviousTxnLgrSeq { get; set; } + public uint? PreviousTxnLgrSeq { get; set; } } /// @@ -70,6 +66,12 @@ public LODepositPreauth() /// public class AuthorizeCredentialEntry { + // No unknown-field capture here on purpose: this shape is also part of an outgoing + // transaction (DepositPreauth), and a member captured off a node response would + // ride back out inside a transaction the user never put it in. StObject.FromJson + // passes signingOnly only to the top level, so such a member would reach the + // displayed tx_json but not the signed blob. + [JsonPropertyName("Credential")] public AuthorizeCredentialBody Credential { get; set; } } @@ -79,6 +81,12 @@ public class AuthorizeCredentialEntry /// public class AuthorizeCredentialBody { + // No unknown-field capture here on purpose: this shape is also part of an outgoing + // transaction (DepositPreauth), and a member captured off a node response would + // ride back out inside a transaction the user never put it in. StObject.FromJson + // passes signingOnly only to the top level, so such a member would reach the + // displayed tx_json but not the signed blob. + /// /// The account that issued the credential. /// diff --git a/Xrpl/Models/Ledger/LODirectoryNode.cs b/Xrpl/Models/Ledger/LODirectoryNode.cs index 4999a9fd..1c6d3141 100644 --- a/Xrpl/Models/Ledger/LODirectoryNode.cs +++ b/Xrpl/Models/Ledger/LODirectoryNode.cs @@ -11,8 +11,10 @@ namespace Xrpl.Models.Ledger /// Flags of a DirectoryNode ledger object. /// /// - /// stays a raw uint for backwards compatibility; - /// test a bit with (dir.Flags & (uint)DirectoryNodeFlags.lsfNFTokenBuyOffers) != 0. + /// is still a uint-based bit-mask, but is now nullable + /// (**breaking**) to express absence (e.g. inside PreviousFields), where a required field can + /// legitimately be missing. A lifted != returns true when either side is null, so check + /// presence explicitly: dir.Flags is { } f && (f & (uint)DirectoryNodeFlags.lsfNFTokenBuyOffers) != 0. /// [System.Flags] public enum DirectoryNodeFlags : uint @@ -34,18 +36,13 @@ public enum DirectoryNodeFlags : uint public class LODirectoryNode : BaseLedgerEntry { - public LODirectoryNode() - { - LedgerEntryType = LedgerEntryType.DirectoryNode; - } - /// /// A bit-map of boolean flags enabled for this directory. /// See for the values the protocol defines. /// - public uint Flags { get; set; } + public uint? Flags { get; set; } /// - /// The ID of root object for this directory. + /// The ID of root object for this directory. /// public string RootIndex { get; set; } /// diff --git a/Xrpl/Models/Ledger/LOEscrow.cs b/Xrpl/Models/Ledger/LOEscrow.cs index 6e75f4d3..17b70116 100644 --- a/Xrpl/Models/Ledger/LOEscrow.cs +++ b/Xrpl/Models/Ledger/LOEscrow.cs @@ -15,12 +15,6 @@ namespace Xrpl.Models.Ledger /// public class LOEscrow : BaseLedgerEntry, IDestination { - public LOEscrow() - { - LedgerEntryType = LedgerEntryType.Escrow; - } - - /// /// The address of the owner (sender) of this held payment.
/// This is the account that provided the funds, and gets it back if the held payment is @@ -104,11 +98,25 @@ public LOEscrow() /// /// The index of the ledger that contains the transaction that most recently modified this object. /// - public uint PreviousTxnLgrSeq { get; set; } + public uint? PreviousTxnLgrSeq { get; set; } - //todo not found field Flags: number - //A bit-map of boolean flags. No flags are defined for the Escrow type, so - //this value is always 0. + /// + /// A bit-map of boolean flags. + /// + /// + /// Left undeclared on the reasoning that no lsfEscrow* flag exists, so the value is + /// always zero and modelling it buys nothing. The field arrives all the same - on every + /// deleted Escrow node in transaction metadata - and an undeclared field is not an absent + /// one: it went to UnknownFields untyped. + /// + /// A plain number rather than an enum, deliberately: sibling entries type theirs + /// (LOOffer.OfferFlags and the rest) because their flags are defined, and inventing + /// an empty enum here would claim a vocabulary that does not exist. If lsfEscrow* + /// flags are ever defined, this becomes that enum. + /// + /// + [JsonPropertyName("Flags")] + public uint? Flags { get; set; } /// Sequence (or ticket) of the EscrowCreate transaction that created this escrow. diff --git a/Xrpl/Models/Ledger/LOFeeSettings.cs b/Xrpl/Models/Ledger/LOFeeSettings.cs index 4b2fc02b..71e67e1b 100644 --- a/Xrpl/Models/Ledger/LOFeeSettings.cs +++ b/Xrpl/Models/Ledger/LOFeeSettings.cs @@ -9,15 +9,11 @@ namespace Xrpl.Models.Ledger ///
public class LOFeeSettings : BaseLedgerEntry { - public LOFeeSettings() - { - LedgerEntryType = LedgerEntryType.FeeSettings; - } /// /// A bit-map of boolean flags for this object.
/// No flags are defined for this type ///
- public uint Flags { get; set; } + public uint? Flags { get; set; } /// /// The transaction cost of the "reference transaction" in drops of XRP as hexadecimal. @@ -26,15 +22,15 @@ public LOFeeSettings() /// /// The BaseFee translated into "fee units". /// - public uint ReferenceFeeUnits { get; set; } + public uint? ReferenceFeeUnits { get; set; } /// /// The base reserve for an account in the XRP Ledger, as drops of XRP. /// - public uint ReserveBase { get; set; } + public uint? ReserveBase { get; set; } /// /// The incremental owner reserve for owning objects, as drops of XRP. /// - public uint ReserveIncrement { get; set; } + public uint? ReserveIncrement { get; set; } /// XRPFees: base fee in drops. [JsonPropertyName("BaseFeeDrops")] diff --git a/Xrpl/Models/Ledger/LOLedger.cs b/Xrpl/Models/Ledger/LOLedger.cs index ec0d1068..95b58279 100644 --- a/Xrpl/Models/Ledger/LOLedger.cs +++ b/Xrpl/Models/Ledger/LOLedger.cs @@ -1,4 +1,5 @@ -using System.Text.Json.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; using System; using System.Collections.Generic; @@ -38,22 +39,93 @@ public class LOLedger : LOBaseLedger //todo rename to LedgerResponse : BaseRespo /// [JsonPropertyName("validated")] public bool Validated { get; set; } + + /// + /// The most recently closed ledger, when the request named no ledger at all. + /// + /// + /// A ledger call that names nothing gets back two whole structures rather than one, + /// and neither is . Not to be confused with + /// BaseLedgerEntity.Closed, which is the boolean inside a ledger saying + /// whether that one ledger is closed - the two spell the same word and mean different + /// things. + /// + [JsonPropertyName("closed")] + public LedgerSide ClosedLedger { get; set; } + + /// + /// The current open ledger, when the request named no ledger at all. + /// + /// + [JsonPropertyName("open")] + public LedgerSide OpenLedger { get; set; } + + // Unknown-field capture is inherited from LOBaseLedger. It was declared here too, back + // when the base carried none: two declarations in one hierarchy compile - the derived one + // hides the base (CS0108) and System.Text.Json binds the derived one - so nothing failed + // visibly, while the base property stayed null forever. LedgerClosed hands callers an + // LOBaseLedger, which would have read empty with the data sitting on the subclass. + } + + /// + /// One of the two ledgers a ledger call returns when it was asked for neither. + /// + public class LedgerSide : BaseMethodResult + { + /// + /// The ledger header. + /// + [JsonPropertyName("ledger")] + [JsonConverter(typeof(LedgerBinaryConverter))] + public IBaseLedgerEntity LedgerEntity { get; set; } } public abstract class BaseLedgerEntity : IBaseLedgerEntity { - /// Whether or not this ledger has been closed. + /// + /// Members the node sent that no declared property here claims. Mirrors + /// for ledger entries and + /// for command results: + /// without it, anything this model does not yet know about is dropped between the node and + /// the caller instead of surviving the round trip. + /// + [JsonExtensionData] + public Dictionary UnknownFields { get; set; } + + /// + /// Whether or not this ledger has been closed. + /// + /// + /// rippled's LedgerToJson.cpp fillJson omits this member entirely for a non-binary + /// response when the ledger is open (closed == false) and the request asked for + /// full: true — the only combination where the field is dropped instead of written + /// as true/false. + /// [JsonPropertyName("closed")] - public bool Closed { get; set; } + public bool? Closed { get; set; } } public interface IBaseLedgerEntity { + /// + /// Members the node sent that no declared property claims - see + /// for what capture is and what it costs. + /// + /// + /// On the interface, not only on : + /// is typed as this interface, so without it a caller holding the ledger of a ledger + /// response cannot read a captured field without casting to a concrete type - and which + /// concrete type it is depends on whether the request asked for binary. + /// + public Dictionary UnknownFields { get; set; } + /// Whether or not this ledger has been closed. - public bool Closed { get; set; } + public bool? Closed { get; set; } } public class LedgerBinaryEntity : BaseLedgerEntity { + // Unknown-field capture is inherited from BaseLedgerEntity - see the note on LOLedger. + [JsonPropertyName("ledger_data")] public string LedgerData { get; set; } @@ -66,6 +138,8 @@ public class LedgerBinaryEntity : BaseLedgerEntity /// public class LedgerEntity : BaseLedgerEntity //todo rename to Ledger https://github.com/XRPLF/xrpl.js/blob/b20c05c3680d80344006d20c44b4ae1c3b0ffcac/packages/xrpl/src/models/ledger/Ledger.ts#L11 { + // Unknown-field capture is inherited from BaseLedgerEntity - see the note on LOLedger. + /// /// The SHA-512Half of this ledger's state tree information. /// @@ -153,6 +227,16 @@ public class LedgerEntity : BaseLedgerEntity //todo rename to Ledger https://git public class QueuedTransaction //todo Rename to LedgerQueueData https://github.com/XRPLF/xrpl.js/blob/b20c05c3680d80344006d20c44b4ae1c3b0ffcac/packages/xrpl/src/models/methods/ledger.ts#L87 { + /// + /// Members the node sent that no declared property here claims. Mirrors + /// for ledger entries and + /// for command results: + /// without it, anything this model does not yet know about is dropped between the node and + /// the caller instead of surviving the round trip. + /// + [JsonExtensionData] + public Dictionary UnknownFields { get; set; } + /// /// The Address of the sender for this queued transaction. /// diff --git a/Xrpl/Models/Ledger/LOLedgerCurrentIndex.cs b/Xrpl/Models/Ledger/LOLedgerCurrentIndex.cs index 8a01674d..43882a6b 100644 --- a/Xrpl/Models/Ledger/LOLedgerCurrentIndex.cs +++ b/Xrpl/Models/Ledger/LOLedgerCurrentIndex.cs @@ -1,4 +1,6 @@  +using System.Collections.Generic; +using System.Text.Json; using System.Text.Json.Serialization; //https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/src/models/methods/ledgerCurrent.ts namespace Xrpl.Models.Ledger @@ -8,6 +10,16 @@ namespace Xrpl.Models.Ledger /// public class LOLedgerCurrentIndex //todo rename to LedgerCurrentResponse { + /// + /// Members the node sent that no declared property here claims. Mirrors + /// for ledger entries and + /// for command results: + /// without it, anything this model does not yet know about is dropped between the node and + /// the caller instead of surviving the round trip. + /// + [JsonExtensionData] + public Dictionary UnknownFields { get; set; } + /// /// The ledger index of this ledger version. /// diff --git a/Xrpl/Models/Ledger/LOLedgerData.cs b/Xrpl/Models/Ledger/LOLedgerData.cs index 1fdba88b..a0b858a5 100644 --- a/Xrpl/Models/Ledger/LOLedgerData.cs +++ b/Xrpl/Models/Ledger/LOLedgerData.cs @@ -1,4 +1,5 @@ -using System.Text.Json.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Collections.Generic; @@ -12,6 +13,16 @@ namespace Xrpl.Models.Ledger /// public class LOLedgerData //todo rename to LedgerDataResponse :BaseResponse { + /// + /// Members the node sent that no declared property here claims. Mirrors + /// for ledger entries and + /// for command results: + /// without it, anything this model does not yet know about is dropped between the node and + /// the caller instead of surviving the round trip. + /// + [JsonExtensionData] + public Dictionary UnknownFields { get; set; } + /// /// The ledger index of this ledger version. /// diff --git a/Xrpl/Models/Ledger/LOLedgerHashes.cs b/Xrpl/Models/Ledger/LOLedgerHashes.cs index 634dce59..8f59f7d6 100644 --- a/Xrpl/Models/Ledger/LOLedgerHashes.cs +++ b/Xrpl/Models/Ledger/LOLedgerHashes.cs @@ -11,16 +11,11 @@ namespace Xrpl.Models.Ledger /// public class LOLedgerHashes : BaseLedgerEntry { - public LOLedgerHashes() - { - LedgerEntryType = LedgerEntryType.LedgerHashes; - } - - public uint FirstLedgerSequence { get; set; } //todo unknown field + public uint? FirstLedgerSequence { get; set; } //todo unknown field /// /// The Ledger Index of the last entry in this object's Hashes array. /// - public uint LastLedgerSequence { get; set; } + public uint? LastLedgerSequence { get; set; } /// /// An array of up to 256 ledger hashes. The contents depend on which sub-type of LedgerHashes object this is. /// @@ -28,6 +23,6 @@ public LOLedgerHashes() /// /// A bit-map of boolean flags for this object. No flags are defined for this type /// - public uint Flags { get; set; } + public uint? Flags { get; set; } } } diff --git a/Xrpl/Models/Ledger/LOLoan.cs b/Xrpl/Models/Ledger/LOLoan.cs index 3b3dafe8..7656144c 100644 --- a/Xrpl/Models/Ledger/LOLoan.cs +++ b/Xrpl/Models/Ledger/LOLoan.cs @@ -36,11 +36,6 @@ public enum LoanFlags : uint /// Requires the Loan amendment (XLS-66d). This feature is in draft and subject to change. public class LOLoan : BaseLedgerEntry { - public LOLoan() - { - LedgerEntryType = LedgerEntryType.Loan; - } - /// /// A bit-map of boolean flags enabled for this loan. /// diff --git a/Xrpl/Models/Ledger/LOLoanBroker.cs b/Xrpl/Models/Ledger/LOLoanBroker.cs index de19eaae..1198feba 100644 --- a/Xrpl/Models/Ledger/LOLoanBroker.cs +++ b/Xrpl/Models/Ledger/LOLoanBroker.cs @@ -10,11 +10,6 @@ namespace Xrpl.Models.Ledger; /// Requires the Loan amendment (XLS-66d). This feature is in draft and subject to change. public class LOLoanBroker : BaseLedgerEntry { - public LOLoanBroker() - { - LedgerEntryType = LedgerEntryType.LoanBroker; - } - /// /// The address of the LoanBroker pseudo-account. /// diff --git a/Xrpl/Models/Ledger/LOMPToken.cs b/Xrpl/Models/Ledger/LOMPToken.cs index e46a0449..2ccec5a8 100644 --- a/Xrpl/Models/Ledger/LOMPToken.cs +++ b/Xrpl/Models/Ledger/LOMPToken.cs @@ -32,11 +32,6 @@ public enum MPTokenFlags : uint /// public class LOMPToken : BaseLedgerEntry { - public LOMPToken() - { - LedgerEntryType = LedgerEntryType.MPToken; - } - /// /// A bit-map of boolean flags enabled for this MPToken. /// diff --git a/Xrpl/Models/Ledger/LOMPTokenIssuance.cs b/Xrpl/Models/Ledger/LOMPTokenIssuance.cs index d01a39af..192b90f4 100644 --- a/Xrpl/Models/Ledger/LOMPTokenIssuance.cs +++ b/Xrpl/Models/Ledger/LOMPTokenIssuance.cs @@ -63,11 +63,6 @@ public enum MPTokenIssuanceFlags : uint /// public class LOMPTokenIssuance : BaseLedgerEntry { - public LOMPTokenIssuance() - { - LedgerEntryType = LedgerEntryType.MPTokenIssuance; - } - [JsonPropertyName("Flags")] public MPTokenIssuanceFlags? Flags { get; init; } @@ -83,7 +78,7 @@ public LOMPTokenIssuance() /// UInt8 /// [JsonPropertyName("AssetScale")] - public byte AssetScale { get; init; } + public byte? AssetScale { get; init; } /// /// Maximum number of tokens that can exist. @@ -100,7 +95,7 @@ public LOMPTokenIssuance() /// [JsonPropertyName("OutstandingAmount")] [JsonConverter(typeof(UInt64StringJsonConverter))] - public ulong OutstandingAmount { get; init; } + public ulong? OutstandingAmount { get; init; } /// /// Amount of tokens currently locked (included in OutstandingAmount). @@ -157,14 +152,14 @@ public LOMPTokenIssuance() /// UInt32 /// [JsonPropertyName("PreviousTxnLgrSeq")] - public uint PreviousTxnLgrSeq { get; init; } + public uint? PreviousTxnLgrSeq { get; init; } /// /// Sequence or Ticket number that created this issuance. /// UInt32 /// [JsonPropertyName("Sequence")] - public uint Sequence { get; init; } + public uint? Sequence { get; init; } /// /// PermissionedDomain restricting who may hold this MPT. @@ -208,8 +203,10 @@ public LOMPTokenIssuance() /// Computed 192-bit MPTokenIssuanceID (48 hex chars, uppercase). /// Derived from and per XLS-33. /// + // Null when Sequence is absent (e.g. this model represents PreviousFields content): + // the deterministic ID formula requires the creating Sequence and would be wrong to fabricate. [JsonIgnore] - public string MPTokenIssuanceID => - ParseMPTID.GenerateMPTokenIssuanceID(Sequence, Issuer); + public string? MPTokenIssuanceID => + Sequence.HasValue ? ParseMPTID.GenerateMPTokenIssuanceID(Sequence.Value, Issuer) : null; } } \ No newline at end of file diff --git a/Xrpl/Models/Ledger/LONFTokenOffer.cs b/Xrpl/Models/Ledger/LONFTokenOffer.cs index f6d49234..87f34436 100644 --- a/Xrpl/Models/Ledger/LONFTokenOffer.cs +++ b/Xrpl/Models/Ledger/LONFTokenOffer.cs @@ -20,15 +20,10 @@ public enum NFTokenOffer public class LONFTokenOffer : BaseLedgerEntry, IDestination { - public LONFTokenOffer() - { - //The type of ledger object (0x0074). - LedgerEntryType = LedgerEntryType.NFTokenOffer; - } /// /// A set of flags associated with this object, used to specify various options or settings. Flags are listed in the table below. /// - public uint Flags { get; set; } + public uint? Flags { get; set; } /// /// Amount expected or offered for the NFToken. If the token has the lsfOnlyXRP flag set, the amount must be specified in XRP.
@@ -76,7 +71,7 @@ public LONFTokenOffer() /// Index of the ledger that contains the transaction that most recently modified this object. ///
[JsonPropertyName("PreviousTxnLgrSeq")] - public uint PreviousTransactionLedgerSequence { get; set; } + public uint? PreviousTransactionLedgerSequence { get; set; } } } diff --git a/Xrpl/Models/Ledger/LONFTokenPage.cs b/Xrpl/Models/Ledger/LONFTokenPage.cs index 31a555aa..283c47ef 100644 --- a/Xrpl/Models/Ledger/LONFTokenPage.cs +++ b/Xrpl/Models/Ledger/LONFTokenPage.cs @@ -9,12 +9,6 @@ namespace Xrpl.Models.Methods; public class LONFTokenPage : BaseLedgerEntry { - - public LONFTokenPage() - { - //The type of ledger object (0x0074). - LedgerEntryType = LedgerEntryType.NFTokenPage; - } [JsonConverter(typeof(NumberOrStringConverter))] public string Flags { get; set; } /// @@ -35,7 +29,8 @@ public LONFTokenPage() /// /// The sequence of the ledger that contains the transaction that most recently modified this NFTokenPage object. /// - public long PreviousTxnLgrSeq { get; set; } + // UInt32 per definitions.json, matching every other ledger entry's PreviousTxnLgrSeq; the prior `long` was wider than the protocol field. + public uint? PreviousTxnLgrSeq { get; set; } /// The locator of the next page, if any, in the owner's NFToken directory. [JsonPropertyName("NextPageMin")] diff --git a/Xrpl/Models/Ledger/LONegativeUNL.cs b/Xrpl/Models/Ledger/LONegativeUNL.cs index bc7f8c92..e70f0c88 100644 --- a/Xrpl/Models/Ledger/LONegativeUNL.cs +++ b/Xrpl/Models/Ledger/LONegativeUNL.cs @@ -10,10 +10,6 @@ namespace Xrpl.Models.Ledger /// public class LONegativeUNL : BaseLedgerEntry { - public LONegativeUNL() - { - LedgerEntryType = LedgerEntryType.NegativeUNL; - } /// /// A list of trusted validators that are currently disabled. /// diff --git a/Xrpl/Models/Ledger/LOOffer.cs b/Xrpl/Models/Ledger/LOOffer.cs index eacf4f05..c4b7ed6b 100644 --- a/Xrpl/Models/Ledger/LOOffer.cs +++ b/Xrpl/Models/Ledger/LOOffer.cs @@ -1,4 +1,5 @@ -using System; +using System.Text.Json; +using System; using System.Collections.Generic; using System.Text.Json.Serialization; @@ -18,6 +19,16 @@ namespace Xrpl.Models.Ledger /// public class BookReference { + /// + /// Members the node sent that no declared property here claims. Mirrors + /// for ledger entries and + /// for command results: + /// without it, anything this model does not yet know about is dropped between the node and + /// the caller instead of surviving the round trip. + /// + [JsonExtensionData] + public Dictionary UnknownFields { get; set; } + /// /// The ID of the offer directory that links to this offer. /// @@ -36,6 +47,16 @@ public class BookReference /// public class BookWrapper { + /// + /// Members the node sent that no declared property here claims. Mirrors + /// for ledger entries and + /// for command results: + /// without it, anything this model does not yet know about is dropped between the node and + /// the caller instead of surviving the round trip. + /// + [JsonExtensionData] + public Dictionary UnknownFields { get; set; } + /// /// The inner Book object containing directory reference. /// @@ -46,10 +67,6 @@ public class BookWrapper public class LOOffer : BaseLedgerEntry { - public LOOffer() - { - LedgerEntryType = LedgerEntryType.Offer; - } /// /// The address of the account that placed this Offer. /// @@ -58,12 +75,12 @@ public LOOffer() /// A bit-map of boolean flags enabled for this Offer. /// Uses OfferFlags enum which includes lsfPassive, lsfSell, and lsfHybrid flags. /// - public OfferFlags Flags { get; set; } + public OfferFlags? Flags { get; set; } /// /// The Sequence value of the OfferCreate transaction that created this Offer object.
/// Used in combination with the Account to identify this Offer. ///
- public uint Sequence { get; set; } + public uint? Sequence { get; set; } /// /// The remaining amount and type of currency requested by the Offer creator. /// @@ -96,7 +113,7 @@ public LOOffer() /// The index of the ledger that contains the transaction that most recently modified this object. /// [JsonPropertyName("PreviousTxnLgrSeq")] - public uint PreviousTxnLgrSeq { get; set; } + public uint? PreviousTxnLgrSeq { get; set; } /// /// The time this Offer expires, in seconds since the Ripple Epoch. /// diff --git a/Xrpl/Models/Ledger/LOOracle.cs b/Xrpl/Models/Ledger/LOOracle.cs index 7b224fd9..62b3073a 100644 --- a/Xrpl/Models/Ledger/LOOracle.cs +++ b/Xrpl/Models/Ledger/LOOracle.cs @@ -16,14 +16,6 @@ namespace Xrpl.Models.Ledger /// public class LOOracle : BaseLedgerEntry { - /// - /// Initializes a new instance of the LOOracle class. - /// - public LOOracle() - { - LedgerEntryType = LedgerEntryType.Oracle; - } - /// /// The XRPL account with update and delete privileges for the oracle. /// It's recommended to set up multi-signing on this account. @@ -87,14 +79,14 @@ public LOOracle() /// The ledger index that this object was most recently modified or created in. /// [JsonPropertyName("PreviousTxnLgrSeq")] - public uint PreviousTxnLgrSeq { get; set; } + public uint? PreviousTxnLgrSeq { get; set; } /// /// A bit-map of boolean flags. No flags are defined for the Oracle object type, /// so this value is always 0. /// [JsonPropertyName("Flags")] - public uint Flags { get; set; } + public uint? Flags { get; set; } /// The document id distinguishing this oracle among the owner's oracles. [JsonPropertyName("OracleDocumentID")] diff --git a/Xrpl/Models/Ledger/LOPayChannel.cs b/Xrpl/Models/Ledger/LOPayChannel.cs index a6254615..50429404 100644 --- a/Xrpl/Models/Ledger/LOPayChannel.cs +++ b/Xrpl/Models/Ledger/LOPayChannel.cs @@ -16,15 +16,11 @@ namespace Xrpl.Models.Ledger /// public class LOPayChannel : BaseLedgerEntry, IDestination { - public LOPayChannel() - { - LedgerEntryType = LedgerEntryType.PayChannel; - } /// /// A bit-map of boolean flags enabled for this payment channel.
/// Currently, the protocol defines no flags for PayChannel objects. ///
- public uint Flags { get; set; } + public uint? Flags { get; set; } /// /// The source address that owns this payment channel.
/// This comes from the sending address of the transaction that created the channel. @@ -63,7 +59,7 @@ public LOPayChannel() /// after the source address requests to close the channel.
/// Can be any value that fits in a 32-bit unsigned integer (0 to 2^32-1). This is set by the transaction that creates the channel. ///
- public uint SettleDelay { get; set; } + public uint? SettleDelay { get; set; } /// /// A hint indicating which page of the source address's owner directory links to this object, /// in case the directory consists of multiple pages. @@ -76,7 +72,7 @@ public LOPayChannel() /// /// The index of the ledger that contains the transaction that most recently modified this object. /// - public uint PreviousTxnLgrSeq { get; set; } + public uint? PreviousTxnLgrSeq { get; set; } /// /// The mutable expiration time for this payment channel, in seconds since the Ripple Epoch.
/// The channel is expired if this value is present and smaller than the previous ledger's close_time field. @@ -94,11 +90,11 @@ public LOPayChannel() /// /// An arbitrary tag to further specify the source for this payment channel useful for specifying a hosted recipient at the owner's address. /// - public uint SourceTag { get; set; } + public uint? SourceTag { get; set; } /// /// An arbitrary tag to further specify the destination for this payment channel, such as a hosted recipient at the destination address. /// - public uint DestinationTag { get; set; } + public uint? DestinationTag { get; set; } /// Sequence (or ticket) of the PaymentChannelCreate that created this channel. [JsonPropertyName("Sequence")] diff --git a/Xrpl/Models/Ledger/LOPermissionedDomain.cs b/Xrpl/Models/Ledger/LOPermissionedDomain.cs index 5d695770..e6aa0abe 100644 --- a/Xrpl/Models/Ledger/LOPermissionedDomain.cs +++ b/Xrpl/Models/Ledger/LOPermissionedDomain.cs @@ -15,14 +15,6 @@ namespace Xrpl.Models.Ledger ///
public class LOPermissionedDomain : BaseLedgerEntry { - /// - /// Initializes a new instance of the LOPermissionedDomain class. - /// - public LOPermissionedDomain() - { - LedgerEntryType = LedgerEntryType.PermissionedDomain; - } - /// /// The address of the account that owns this domain. /// @@ -40,7 +32,7 @@ public LOPermissionedDomain() /// The Sequence value of the transaction that created this entry. ///
[JsonPropertyName("Sequence")] - public uint Sequence { get; set; } + public uint? Sequence { get; set; } /// /// A list of 1 to 10 Credential objects that grant access to this domain. @@ -59,6 +51,6 @@ public LOPermissionedDomain() /// The index of the ledger that contains the transaction that most recently modified this object. /// [JsonPropertyName("PreviousTxnLgrSeq")] - public uint PreviousTxnLgrSeq { get; set; } + public uint? PreviousTxnLgrSeq { get; set; } } } diff --git a/Xrpl/Models/Ledger/LORippleState.cs b/Xrpl/Models/Ledger/LORippleState.cs index 4c2bab54..fa956e90 100644 --- a/Xrpl/Models/Ledger/LORippleState.cs +++ b/Xrpl/Models/Ledger/LORippleState.cs @@ -47,14 +47,10 @@ public enum RippleStateFlags /// public class LORippleState : BaseLedgerEntry { - public LORippleState() - { - LedgerEntryType = LedgerEntryType.RippleState; - } /// /// A bit-map of boolean options enabled for this object. /// - public RippleStateFlags Flags { get; set; } + public RippleStateFlags? Flags { get; set; } /// /// The balance of the trust line, from the perspective of the low account.
/// A negative balance indicates that the low account has issued currency to the high account.
@@ -78,7 +74,7 @@ public LORippleState() /// /// The index of the ledger that contains the transaction that most recently modified this object. /// - public uint PreviousTxnLgrSeq { get; set; } + public uint? PreviousTxnLgrSeq { get; set; } /// /// A hint indicating which page of the low account's owner directory links to this object, /// in case the directory consists of multiple pages. diff --git a/Xrpl/Models/Ledger/LOSignerList.cs b/Xrpl/Models/Ledger/LOSignerList.cs index e608fd65..89118ddf 100644 --- a/Xrpl/Models/Ledger/LOSignerList.cs +++ b/Xrpl/Models/Ledger/LOSignerList.cs @@ -13,8 +13,9 @@ namespace Xrpl.Models.Ledger; /// Flags of a SignerList ledger object. /// /// -/// stays a raw uint for backwards compatibility; -/// test a bit with (list.Flags & (uint)SignerListFlags.lsfOneOwnerCount) != 0. +/// stays a raw uint for backwards compatibility, but is +/// nullable (absent e.g. inside PreviousFields). A lifted != returns true when either side +/// is null, so check presence explicitly: list.Flags is { } f && (f & (uint)SignerListFlags.lsfOneOwnerCount) != 0. /// [Flags] public enum SignerListFlags : uint @@ -33,14 +34,11 @@ public enum SignerListFlags : uint ///
public class LOSignerList : BaseLedgerEntry { - /// create base object - public LOSignerList() => LedgerEntryType = LedgerEntryType.SignerList; - /// /// A bit-map of Boolean flags enabled for this signer list.
/// For more information, see SignerList Flags. ///
- public uint Flags { get; set; } + public uint? Flags { get; set; } /// /// A hint indicating which page of the owner directory links to this object, in case the directory consists of multiple pages. @@ -52,7 +50,7 @@ public class LOSignerList : BaseLedgerEntry /// To produce a valid signature for the owner of this SignerList, /// the signers must provide valid signatures whose weights sum to this value or more. /// - public uint SignerQuorum { get; set; } + public uint? SignerQuorum { get; set; } /// /// An array of Signer Entry objects representing the parties who are part of this signer list. @@ -65,7 +63,7 @@ public class LOSignerList : BaseLedgerEntry /// If a future amendment allows multiple signer lists for an account, this may change. /// [JsonPropertyName("SignerListID")] - public uint SignerListId { get; set; } + public uint? SignerListId { get; set; } /// /// The identifying hash of the transaction that most recently modified this object. @@ -75,7 +73,7 @@ public class LOSignerList : BaseLedgerEntry /// /// The index of the ledger that contains the transaction that most recently modified this object. /// - public uint PreviousTxnLgrSeq { get; set; } + public uint? PreviousTxnLgrSeq { get; set; } /// Owner of the signer list (present in some historical entries). [JsonPropertyName("Owner")] diff --git a/Xrpl/Models/Ledger/LOSponsorship.cs b/Xrpl/Models/Ledger/LOSponsorship.cs index f051008f..68666777 100644 --- a/Xrpl/Models/Ledger/LOSponsorship.cs +++ b/Xrpl/Models/Ledger/LOSponsorship.cs @@ -26,16 +26,11 @@ public enum SponsorshipFlags : uint /// Requires the Sponsor amendment (XLS-68). public class LOSponsorship : BaseLedgerEntry { - public LOSponsorship() - { - LedgerEntryType = LedgerEntryType.Sponsorship; - } - /// /// A bit-map of boolean flags (see ). /// [JsonPropertyName("Flags")] - public SponsorshipFlags Flags { get; init; } + public SponsorshipFlags? Flags { get; init; } /// /// The sponsoring account. diff --git a/Xrpl/Models/Ledger/LOTicket.cs b/Xrpl/Models/Ledger/LOTicket.cs index 3608023b..2f20915a 100644 --- a/Xrpl/Models/Ledger/LOTicket.cs +++ b/Xrpl/Models/Ledger/LOTicket.cs @@ -12,21 +12,14 @@ namespace Xrpl.Models.Ledger /// public class LOTicket : BaseLedgerEntry { - public LOTicket() - { - LedgerEntryType = LedgerEntryType.Ticket; - } - /// - /// The sender of the Check. Cashing the Check debits this address's balance.
/// The account that owns this Ticket. ///
public string Account { get; set; } /// /// A bit-map of Boolean flags enabled for this Ticket.
- /// Currently, there are no flags defined for Tickets.
- /// No flags are defined for Checks, so this value is always 0. + /// Currently, there are no flags defined for Tickets, so this value is always 0. ///
[JsonConverter(typeof(NumberOrStringConverter))] public string Flags { get; set; } @@ -45,13 +38,11 @@ public LOTicket() /// /// The index of the ledger that contains the transaction that most recently modified this object. /// - public uint PreviousTxnLgrSeq { get; set; } + public uint? PreviousTxnLgrSeq { get; set; } /// - /// The maximum amount of currency this Check can debit the sender.
- /// If the Check is successfully cashed, the destination is credited in the same currency for up to this amount.
/// The Sequence Number this Ticket sets aside. ///
- public uint TicketSequence { get; set; } + public uint? TicketSequence { get; set; } } } \ No newline at end of file diff --git a/Xrpl/Models/Ledger/LOVault.cs b/Xrpl/Models/Ledger/LOVault.cs index d8594488..32d67eb3 100644 --- a/Xrpl/Models/Ledger/LOVault.cs +++ b/Xrpl/Models/Ledger/LOVault.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System; using System.Text; using System.Text.Json; @@ -50,6 +51,16 @@ public enum VaultVersion : uint ///
public class VaultDataFormat { + /// + /// Members the node sent that no declared property here claims. Mirrors + /// for ledger entries and + /// for command results: + /// without it, anything this model does not yet know about is dropped between the node and + /// the caller instead of surviving the round trip. + /// + [JsonExtensionData] + public Dictionary UnknownFields { get; set; } + /// /// Human-readable vault identifier reflecting its strategy (short key: "n"). /// @@ -98,11 +109,6 @@ public static VaultDataFormat FromHex(string hex) /// Requires the Vault amendment (XLS-65d). This feature is in draft and subject to change. public class LOVault : BaseLedgerEntry { - public LOVault() - { - LedgerEntryType = LedgerEntryType.Vault; - } - /// /// The address of the vault's pseudo-account. /// diff --git a/Xrpl/Models/Ledger/LOXChainOwnedClaimID.cs b/Xrpl/Models/Ledger/LOXChainOwnedClaimID.cs index 542b0eb3..ba0a8b1d 100644 --- a/Xrpl/Models/Ledger/LOXChainOwnedClaimID.cs +++ b/Xrpl/Models/Ledger/LOXChainOwnedClaimID.cs @@ -12,11 +12,6 @@ namespace Xrpl.Models.Ledger; /// public class LOXChainOwnedClaimID : BaseLedgerEntry { - public LOXChainOwnedClaimID() - { - LedgerEntryType = LedgerEntryType.XChainOwnedClaimID; - } - /// /// The account that owns this claim ID. /// diff --git a/Xrpl/Models/Ledger/LOXChainOwnedCreateAccountClaimID.cs b/Xrpl/Models/Ledger/LOXChainOwnedCreateAccountClaimID.cs index 1b853b72..8c8255cb 100644 --- a/Xrpl/Models/Ledger/LOXChainOwnedCreateAccountClaimID.cs +++ b/Xrpl/Models/Ledger/LOXChainOwnedCreateAccountClaimID.cs @@ -11,11 +11,6 @@ namespace Xrpl.Models.Ledger; /// public class LOXChainOwnedCreateAccountClaimID : BaseLedgerEntry { - public LOXChainOwnedCreateAccountClaimID() - { - LedgerEntryType = LedgerEntryType.XChainOwnedCreateAccountClaimID; - } - /// /// The account that owns this object. /// diff --git a/Xrpl/Models/Ledger/LedgerEntryResponse.cs b/Xrpl/Models/Ledger/LedgerEntryResponse.cs index ab91cf75..8bbb462a 100644 --- a/Xrpl/Models/Ledger/LedgerEntryResponse.cs +++ b/Xrpl/Models/Ledger/LedgerEntryResponse.cs @@ -1,4 +1,6 @@ -using System.Text.Json.Serialization; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; using Xrpl.Client.Json.Converters; @@ -7,6 +9,16 @@ namespace Xrpl.Models.Ledger { public class LedgerEntryResponse //todo rename LedgerEntryResponse: BaseResponse { + /// + /// Members the node sent that no declared property here claims. Mirrors + /// for ledger entries and + /// for command results: + /// without it, anything this model does not yet know about is dropped between the node and + /// the caller instead of surviving the round trip. + /// + [JsonExtensionData] + public Dictionary UnknownFields { get; set; } + [JsonPropertyName("index")] public string Index { get; set; } diff --git a/Xrpl/Models/Methods/AMMInfo.cs b/Xrpl/Models/Methods/AMMInfo.cs index 7fe77d73..9595beef 100644 --- a/Xrpl/Models/Methods/AMMInfo.cs +++ b/Xrpl/Models/Methods/AMMInfo.cs @@ -48,7 +48,7 @@ public class AMMInfoRequest : BaseLedgerRequest /// /// Response expected from an . /// -public class AMMInfoResponse +public class AMMInfoResponse : BaseMethodResult { [JsonPropertyName("amm")] public AMMInfo Amm { get; set; } @@ -72,7 +72,7 @@ public class AMMInfoResponse public bool? Validated { get; set; } } -public class AMMInfo +public class AMMInfo : BaseMethodResult { /// /// The account that tracks the balance of LPTokens between the AMM instance via Trustline. diff --git a/Xrpl/Models/Methods/AccountChannels.cs b/Xrpl/Models/Methods/AccountChannels.cs index e3e98724..5e985821 100644 --- a/Xrpl/Models/Methods/AccountChannels.cs +++ b/Xrpl/Models/Methods/AccountChannels.cs @@ -11,7 +11,7 @@ namespace Xrpl.Models.Methods /// /// The expected response from an . /// - public class AccountChannels //todo rename to AccountChannelsResponse + public class AccountChannels : BaseMethodResult//todo rename to AccountChannelsResponse { /// /// The address of the source/owner of the payment channels.
@@ -45,7 +45,7 @@ public class AccountChannels //todo rename to AccountChannelsResponse /// Payment channel owned by account. /// https://xrpl.org/account_channels.html ///
- public class Channel + public class Channel : BaseMethodResult { /// /// The owner of the channel, as an Address. diff --git a/Xrpl/Models/Methods/AccountCurrencies.cs b/Xrpl/Models/Methods/AccountCurrencies.cs index 31cc913e..f9b8cb50 100644 --- a/Xrpl/Models/Methods/AccountCurrencies.cs +++ b/Xrpl/Models/Methods/AccountCurrencies.cs @@ -9,7 +9,7 @@ namespace Xrpl.Models.Methods /// /// The expected response from an . /// - public class AccountCurrencies //todo rename to AccountCurrenciesResponse + public class AccountCurrencies : BaseMethodResult//todo rename to AccountCurrenciesResponse { /// /// The identifying hash of the ledger version used to retrieve this data as hex. @@ -20,7 +20,7 @@ public class AccountCurrencies //todo rename to AccountCurrenciesResponse /// The ledger index of the ledger version used to retrieve this data. /// [JsonPropertyName("ledger_index")] - public int LedgerIndex { get; set; } + public int? LedgerIndex { get; set; } /// /// Array of Currency Codes for currencies that this account can receive. /// diff --git a/Xrpl/Models/Methods/AccountInfo.cs b/Xrpl/Models/Methods/AccountInfo.cs index abb1d4b0..c842a0f6 100644 --- a/Xrpl/Models/Methods/AccountInfo.cs +++ b/Xrpl/Models/Methods/AccountInfo.cs @@ -11,7 +11,7 @@ namespace Xrpl.Models.Methods /// /// Response expected from an . /// - public class AccountInfo //todo rename to AccountInfoResponse + public class AccountInfo : BaseMethodResult //todo rename to AccountInfoResponse { /// /// The AccountRoot ledger object with this account's information, as stored in the ledger. @@ -34,14 +34,14 @@ public class AccountInfo //todo rename to AccountInfoResponse /// The ledger index of the current in-progress ledger, which was used when retrieving this information. /// [JsonPropertyName("ledger_current_index")] - public int LedgerCurrentIndex { get; set; } + public int? LedgerCurrentIndex { get; set; } /// /// The ledger index of the ledger version used when retrieving this ///information.The information does not contain any changes from ledger /// versions newer than this one. /// [JsonPropertyName("ledger_index")] - public int LedgerIndex { get; set; } + public int? LedgerIndex { get; set; } /// /// Information about queued transactions sent by this account.
/// This information describes the state of the local rippled server, which may be different from other servers in the peer-to-peer XRP Ledger network.
@@ -64,7 +64,7 @@ public class AccountInfo //todo rename to AccountInfoResponse } - public class PseudoAccountInfo + public class PseudoAccountInfo : BaseMethodResult { [JsonPropertyName("type")] public string Type { get; set; } @@ -74,7 +74,7 @@ public class PseudoAccountInfo /// /// Information about each queued transaction from address. /// - public class AccountQueueTransaction + public class AccountQueueTransaction : BaseMethodResult { /// /// Whether this transaction changes this address's ways of authorizing transactions. @@ -102,7 +102,7 @@ public class AccountQueueTransaction [JsonPropertyName("seq")] public int Sequence { get; set; } } - public sealed class AccountInfoAccountFlags + public sealed class AccountInfoAccountFlags : BaseMethodResult { /// /// Enable rippling on this address's trust lines by default. Required for issuing addresses; discouraged for others. @@ -196,7 +196,7 @@ public sealed class AccountInfoAccountFlags /// This information describes the state of the local rippled server, which may be different from other servers in the peer-to-peer XRP Ledger network.
/// Some fields may be omitted because the values are calculated "lazily" by the queuing mechanism. ///
- public class AccountQueueData + public class AccountQueueData : BaseMethodResult { /// /// Whether a transaction in the queue changes this address's ways of authorizing transactions. diff --git a/Xrpl/Models/Methods/AccountLines.cs b/Xrpl/Models/Methods/AccountLines.cs index 7d19a810..0c224269 100644 --- a/Xrpl/Models/Methods/AccountLines.cs +++ b/Xrpl/Models/Methods/AccountLines.cs @@ -13,7 +13,7 @@ namespace Xrpl.Models.Methods; /// /// Response expected from an . /// -public class AccountLines //todo rename to AccountLinesResponse +public class AccountLines : BaseMethodResult//todo rename to AccountLinesResponse { /// /// Unique Address of the account this request corresponds to.
@@ -55,6 +55,19 @@ public class AccountLines //todo rename to AccountLinesResponse [JsonPropertyName("marker")] public object Marker { get; set; } + /// + /// Whether the ledger this answer was read from is validated. + /// + /// + /// rippled writes this through lookupLedger, unconditionally, so it arrives on every + /// account_lines answer. Every sibling result model - AccountInfo, + /// AccountObjects, AccountNFTs, AccountCurrencies, NoRippleCheck - + /// has always declared it; this one alone did not, so it landed in UnknownFields and + /// callers had no typed way to tell a validated answer from a provisional one. + /// + [JsonPropertyName("validated")] + public bool? Validated { get; set; } + [JsonPropertyName("limit")] public int? Limit { get; set; } } @@ -62,7 +75,7 @@ public class AccountLines //todo rename to AccountLinesResponse /// /// Trust line objects. /// -public class TrustLine +public class TrustLine : BaseMethodResult { /// /// The unique Address of the counterparty to this trust line. diff --git a/Xrpl/Models/Methods/AccountNFTs.cs b/Xrpl/Models/Methods/AccountNFTs.cs index 33820e29..9e02acb4 100644 --- a/Xrpl/Models/Methods/AccountNFTs.cs +++ b/Xrpl/Models/Methods/AccountNFTs.cs @@ -12,7 +12,7 @@ namespace Xrpl.Models.Methods /// /// Response expected from an . /// - public class AccountNFTs //todo rename to response + public class AccountNFTs : BaseMethodResult//todo rename to response { /// /// The account requested. @@ -51,7 +51,7 @@ public class AccountNFTs //todo rename to response /// One NFToken that might be returned from an . /// https://xrpl.org/account_nfts.html#account_nfts /// - public class NFT + public class NFT : BaseMethodResult { /// /// A bit-map of boolean flags enabled for this NFToken.
diff --git a/Xrpl/Models/Methods/AccountObjects.cs b/Xrpl/Models/Methods/AccountObjects.cs index be5b0ec8..e44594ec 100644 --- a/Xrpl/Models/Methods/AccountObjects.cs +++ b/Xrpl/Models/Methods/AccountObjects.cs @@ -11,7 +11,7 @@ namespace Xrpl.Models.Methods /// /// Response expected from an . /// - public class AccountObjects //todo rename to response + public class AccountObjects : BaseMethodResult //todo rename to response { /// /// Unique Address of the account this request corresponds to. diff --git a/Xrpl/Models/Methods/AccountOffers.cs b/Xrpl/Models/Methods/AccountOffers.cs index 11521714..6130ce1a 100644 --- a/Xrpl/Models/Methods/AccountOffers.cs +++ b/Xrpl/Models/Methods/AccountOffers.cs @@ -11,7 +11,7 @@ namespace Xrpl.Models.Methods /// /// Response expected from an form an . /// - public class AccountOffers //todo rename to response + public class AccountOffers : BaseMethodResult//todo rename to response { /// /// Unique Address identifying the account that made the offers. @@ -50,7 +50,7 @@ public class AccountOffers //todo rename to response /// /// offer made by account that is outstanding as of the requested ledger version. /// - public class Offer + public class Offer : BaseMethodResult { /// /// Options set for this offer entry as bit-flags. diff --git a/Xrpl/Models/Methods/AccountTransactions.cs b/Xrpl/Models/Methods/AccountTransactions.cs index 5032ebc9..ff7cb662 100644 --- a/Xrpl/Models/Methods/AccountTransactions.cs +++ b/Xrpl/Models/Methods/AccountTransactions.cs @@ -11,7 +11,7 @@ namespace Xrpl.Models.Methods /// /// Expected response from an . /// - public class AccountTransactions //todo rename to response + public class AccountTransactions : BaseMethodResult //todo rename to response { /// /// Unique Address identifying the related account. @@ -90,7 +90,7 @@ public interface IAccountTransaction public bool Validated { get; set; } } - public class TransactionSummary : IAccountTransaction //todo rename to AccountTransaction + public class TransactionSummary : BaseMethodResult, IAccountTransaction //todo rename to AccountTransaction { private TransactionResponse _transaction; private string _hash; @@ -104,6 +104,44 @@ public class TransactionSummary : IAccountTransaction //todo rename to AccountTr [JsonConverter(typeof(FromStringDateTimeConverter))] public DateTime? CloseTimeIso { get; set; } + /// + /// The compact transaction identifier, when rippled reports one. + /// + /// + /// Covers the singular tx method, where ctid sits beside tx_json — + /// this property reads it from there. account_tx instead nests ctid inside + /// tx_json itself, which lands on + /// on the deserialized + /// transaction, not here. + /// + [JsonPropertyName("ctid")] + public string? Ctid { get; set; } + + /// + /// If binary is True, then this is a hex string of the transaction metadata. + /// + /// + /// API v2 with binary: true. rippled sends this as a top-level sibling of + /// tx_blob instead of the usual meta field, and the meta field is + /// absent entirely — so 's string branch, which handles + /// API v1's "meta": "<hex>", never runs for this shape. API v1 binary mode + /// puts the same hex string in instead, reached through + /// below. + /// + [JsonPropertyName("meta_blob")] + public string? MetaBlob { get; set; } + + /// + /// If binary is True, then this is a hex string of the transaction itself. + /// + /// + /// API v2 with binary: true. rippled sends this as a top-level sibling of + /// meta_blob instead of the usual tx_json field, and tx_json is + /// absent entirely — so below is null for this shape. + /// + [JsonPropertyName("tx_blob")] + public string? TxBlob { get; set; } + /// /// A hex string of the ledger version that included this transaction. /// @@ -134,8 +172,27 @@ public ulong? LedgerIndex /// JSON object defining the transaction. /// /// + /// /// rippled wraps the transaction in tx_json under API v2 and in tx under API v1; /// both envelopes populate this property. + /// + /// + /// Match on the I-interface, not on the request type. What arrives here is a + /// - an NFTokenCreateOfferResponse, say - and never + /// the request type of the same name. So: + /// + /// + /// if (summary.Transaction is NFTokenCreateOffer request) // never matches + /// if (summary.Transaction is INFTokenCreateOffer offer) // this is the one + /// + /// + /// The first line compiles, warns about nothing and quietly finds nothing, which looks + /// exactly like the response having failed to parse - so the search starts in the wrong + /// place. Request and response types come in pairs that share an I-interface; use + /// those to read history, and the request types only to send. The five + /// ConfidentialMPT transactions are the exception - neither half declares an + /// interface, so for those there is nothing to match on yet. + /// /// [JsonPropertyName("tx_json")] public TransactionResponse Transaction diff --git a/Xrpl/Models/Methods/BaseMethodResult.cs b/Xrpl/Models/Methods/BaseMethodResult.cs new file mode 100644 index 00000000..c4cd6e34 --- /dev/null +++ b/Xrpl/Models/Methods/BaseMethodResult.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Xrpl.Models.Methods +{ + /// + /// Shared base for rippled command result models that have no ledger-entry or transaction + /// envelope base of their own to carry unknown fields (compare + /// and + /// , which cover + /// those two families). Every result shape below this class is deserialized directly by + /// the ordinary reflection-based deserializer - none of it goes through a type-dispatching + /// converter - so this attribute alone is enough to stop members the model does not declare a + /// property for from silently vanishing. + /// + /// + /// Not a substitute for XrplResponse<T>.Raw: values here have already gone + /// through JSON parsing (numbers, strings, nested objects as ), while + /// Raw is the exact bytes the node sent. Use Raw when byte-for-byte fidelity + /// matters; use this when a caller just needs to read a field the model does not yet declare. + /// + public class BaseMethodResult + { + /// + /// Members of the result that no declared property on the concrete response type claims - + /// a field an amendment adds before this SDK models it, or anything else unrecognized. + /// Declared here rather than repeated on every subclass, mirroring + /// and + /// for their + /// own families. + /// + /// + /// Values here have already gone through JSON parsing - see the class remarks above for + /// how that differs from XrplResponse<T>.Raw. That parsing has a real + /// retention cost, out of proportion to the unknown member's own size: a single large + /// unrecognized value held here alone raised one captured response's retained size from + /// roughly 36 700 B to 65 704 B - about 1.79x, not merely the member's bytes added on top - + /// because a keeps a reference into the pooled buffer backing the + /// it was parsed from rather than owning a right-sized copy. + /// Accepted anyway: the alternative is losing the field outright, which is worse for a + /// caller relying on this to read a member the model does not yet declare. + /// + /// The figure above is for one large member; the cost that actually bites is per-member + /// and multiplies by nesting. Each captured member costs about 464 B regardless of how + /// small its JSON is, so a page of 1 000 nested objects each carrying one unmodelled field + /// retains 792 KB against 320 KB when the field was dropped - 4.33x the JSON's own size + /// rather than 1.75x. Where a field is known to arrive on every message of a busy stream, + /// declare a property for it instead of relying on this - see + /// . + /// + [JsonExtensionData] + public Dictionary UnknownFields { get; set; } + } +} diff --git a/Xrpl/Models/Methods/ChannelAuthorize.cs b/Xrpl/Models/Methods/ChannelAuthorize.cs index c14beab6..f35490fa 100644 --- a/Xrpl/Models/Methods/ChannelAuthorize.cs +++ b/Xrpl/Models/Methods/ChannelAuthorize.cs @@ -108,7 +108,7 @@ public double RippleAmount /// /// Response expected from a . /// - public class ChannelAuthorizeResponse + public class ChannelAuthorizeResponse : BaseMethodResult { /// /// The signature for this claim, as a hexadecimal value. diff --git a/Xrpl/Models/Methods/ChannelVerify.cs b/Xrpl/Models/Methods/ChannelVerify.cs index 012260ea..5ae7dc53 100644 --- a/Xrpl/Models/Methods/ChannelVerify.cs +++ b/Xrpl/Models/Methods/ChannelVerify.cs @@ -73,7 +73,7 @@ public double RippleAmount /// /// Response expected from a . /// - public class ChannelVerifyResponse + public class ChannelVerifyResponse : BaseMethodResult { /// /// If true, the signature is valid for the stated amount, channel, and public key. diff --git a/Xrpl/Models/Methods/DepositAuthorized.cs b/Xrpl/Models/Methods/DepositAuthorized.cs index 81ababc6..693cf79c 100644 --- a/Xrpl/Models/Methods/DepositAuthorized.cs +++ b/Xrpl/Models/Methods/DepositAuthorized.cs @@ -57,7 +57,7 @@ public DepositAuthorizedRequest(string sourceAccount, string destinationAccount) /// /// Response expected from a . /// - public class DepositAuthorized + public class DepositAuthorized : BaseMethodResult { /// /// Whether the specified source account is authorized to send payments directly to the destination account. diff --git a/Xrpl/Models/Methods/Fee.cs b/Xrpl/Models/Methods/Fee.cs index aa6656be..5b03f8f2 100644 --- a/Xrpl/Models/Methods/Fee.cs +++ b/Xrpl/Models/Methods/Fee.cs @@ -25,7 +25,7 @@ public FeeRequest() /// /// Response expected from a . /// - public class Fee //todo rename to FeeResponse : BaseResponse + public class Fee : BaseMethodResult//todo rename to FeeResponse : BaseResponse { /// /// Number of transactions provisionally included in the in-progress ledger. @@ -72,7 +72,7 @@ public class Fee //todo rename to FeeResponse : BaseResponse /// /// The transaction cost /// - public class Drops + public class Drops : BaseMethodResult { /// /// The transaction cost required for a reference transaction to be included in a ledger under minimum load, represented in drops of XRP. @@ -104,7 +104,7 @@ public class Drops /// /// required transaction cost level /// - public class Levels + public class Levels : BaseMethodResult { /// /// The median transaction cost among transactions in the previous validated ledger, represented in fee levels. diff --git a/Xrpl/Models/Methods/NFTBuyOffers.cs b/Xrpl/Models/Methods/NFTBuyOffers.cs index 40b2eb6f..f71cf79e 100644 --- a/Xrpl/Models/Methods/NFTBuyOffers.cs +++ b/Xrpl/Models/Methods/NFTBuyOffers.cs @@ -7,7 +7,7 @@ namespace Xrpl.Models.Methods /// /// Response expected from an .. /// - public class NFTBuyOffers //todo rename to NFTBuyOffersResponse extends BaseResponse + public class NFTBuyOffers : BaseMethodResult//todo rename to NFTBuyOffersResponse extends BaseResponse { /// diff --git a/Xrpl/Models/Methods/NFTHistory.cs b/Xrpl/Models/Methods/NFTHistory.cs new file mode 100644 index 00000000..0a302bc3 --- /dev/null +++ b/Xrpl/Models/Methods/NFTHistory.cs @@ -0,0 +1,124 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +// https://github.com/XRPLF/clio/blob/develop/src/rpc/handlers/NFTHistory.cpp +namespace Xrpl.Models.Methods +{ + /// + /// Response expected from an . + /// + public class NFTHistory : BaseMethodResult + { + /// + /// The token whose history this is. + /// + [JsonPropertyName("nft_id")] + public string NFTokenID { get; set; } + + /// + /// The earliest ledger actually searched. + /// + [JsonPropertyName("ledger_index_min")] + public uint? LedgerIndexMin { get; set; } + + /// + /// The most recent ledger actually searched. + /// + [JsonPropertyName("ledger_index_max")] + public uint? LedgerIndexMax { get; set; } + + /// + /// The limit that was applied. + /// + [JsonPropertyName("limit")] + public uint? Limit { get; set; } + + /// + /// Present when there is more to read; pass it back to continue where this left off. + /// + /// + /// Clio sends an object of ledger and seq here, the same marker + /// account_tx uses. Typed as object for the same reason it is there: the + /// server defines its shape, and a caller's business with it is to hand it back unread. + /// + [JsonPropertyName("marker")] + public object Marker { get; set; } + + /// + /// The transactions that touched this token, newest first unless forward was asked for. + /// + /// + /// The same entries account_tx returns, so the same type reads them - including the + /// tx versus tx_json envelopes of API v1 and v2, which + /// already handles. Match them on the I-interfaces, + /// as with any transaction read back from a ledger. + /// + [JsonPropertyName("transactions")] + public List Transactions { get; set; } + + /// + /// Whether the answer comes from validated ledgers. + /// + [JsonPropertyName("validated")] + public bool? Validated { get; set; } + } + + /// + /// The nft_history method asks what has happened to a token. + /// + /// + /// A Clio method, like : a plain rippled node answers + /// unknownCmd. Paginated the way account_tx is - keep passing + /// back until the answer comes without one. + /// + public class NFTHistoryRequest : BaseLedgerRequest + { + public NFTHistoryRequest(string nft_id) + { + NFTokenID = nft_id; + Command = "nft_history"; + } + + /// + /// The unique identifier of the NFToken whose history to read. + /// + [JsonPropertyName("nft_id")] + public string NFTokenID { get; set; } + + /// + /// The earliest ledger to search. -1 asks for the earliest available. + /// + [JsonPropertyName("ledger_index_min")] + public int? LedgerIndexMin { get; set; } + + /// + /// The most recent ledger to search. -1 asks for the most recent available. + /// + [JsonPropertyName("ledger_index_max")] + public int? LedgerIndexMax { get; set; } + + /// + /// Return transactions as hex strings instead of JSON. + /// + [JsonPropertyName("binary")] + public bool? Binary { get; set; } + + /// + /// Read oldest first instead of newest first. + /// + [JsonPropertyName("forward")] + public bool? Forward { get; set; } + + /// + /// How many transactions to return at most. + /// + [JsonPropertyName("limit")] + public uint? Limit { get; set; } + + /// + /// The marker from a previous answer, to continue from where it stopped. + /// + [JsonPropertyName("marker")] + public object Marker { get; set; } + } +} diff --git a/Xrpl/Models/Methods/NFTInfo.cs b/Xrpl/Models/Methods/NFTInfo.cs new file mode 100644 index 00000000..b828cf8b --- /dev/null +++ b/Xrpl/Models/Methods/NFTInfo.cs @@ -0,0 +1,118 @@ +using System.Text.Json.Serialization; + +// https://github.com/XRPLF/clio/blob/develop/src/rpc/handlers/NFTInfo.cpp +namespace Xrpl.Models.Methods +{ + /// + /// Response expected from an . + /// + public class NFTInfo : BaseMethodResult + { + /// + /// The token this describes. + /// + [JsonPropertyName("nft_id")] + public string NFTokenID { get; set; } + + /// + /// The ledger the answer was read from. + /// + [JsonPropertyName("ledger_index")] + public uint? LedgerIndex { get; set; } + + /// + /// Who holds the token now. + /// + /// + /// The reason this command is worth having. An owner cannot be worked out from + /// : 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 current owner may have made none at all. + /// + [JsonPropertyName("owner")] + public string Owner { get; set; } + + /// + /// Whether the token has been burned, in which case it has no owner any more. + /// + [JsonPropertyName("is_burned")] + public bool? IsBurned { get; set; } + + /// + /// The flags the token was minted with. + /// + [JsonPropertyName("flags")] + public uint? Flags { get; set; } + + /// + /// The issuer's cut of secondary sales, in units of 1/100 000. + /// + [JsonPropertyName("transfer_fee")] + public uint? TransferFee { get; set; } + + /// + /// The account that minted the token. + /// + [JsonPropertyName("issuer")] + public string Issuer { get; set; } + + /// + /// The issuer's own grouping of their tokens. + /// + [JsonPropertyName("nft_taxon")] + public uint? Taxon { get; set; } + + /// + /// The token's sequence within that issuer and taxon. + /// + /// + /// Clio sends this as nft_serial; its own source notes that the documentation calls + /// it nft_sequence. The name here follows what actually arrives on the wire. + /// + [JsonPropertyName("nft_serial")] + public uint? Serial { get; set; } + + /// + /// The URI the token was minted with, as hex. + /// + [JsonPropertyName("uri")] + public string URI { get; set; } + + /// + /// Whether the answer comes from a validated ledger. + /// + [JsonPropertyName("validated")] + public bool? Validated { get; set; } + } + + /// + /// The nft_info method asks who owns a token and what it was minted with. + /// + /// + /// + /// A Clio method, not a rippled one. A plain rippled node answers unknownCmd, which + /// arrives as an ordinary node error rather than something special - a caller who needs to work + /// against both can catch it and fall back. + /// + /// + /// There is no substitute for it on a rippled node. Ownership cannot be read out of + /// nft_sell_offers: a sale leaves the seller's offers in the ledger, so they keep being + /// returned by an account that no longer owns the token, and the new owner usually has no + /// offers at all - which is exactly the state a token is in right after being bought. + /// + /// + public class NFTInfoRequest : BaseLedgerRequest + { + public NFTInfoRequest(string nft_id) + { + NFTokenID = nft_id; + Command = "nft_info"; + } + + /// + /// The unique identifier of the NFToken to describe. + /// + [JsonPropertyName("nft_id")] + public string NFTokenID { get; set; } + } +} diff --git a/Xrpl/Models/Methods/NFTSellOffers.cs b/Xrpl/Models/Methods/NFTSellOffers.cs index cfcc58f4..096588bc 100644 --- a/Xrpl/Models/Methods/NFTSellOffers.cs +++ b/Xrpl/Models/Methods/NFTSellOffers.cs @@ -12,7 +12,7 @@ namespace Xrpl.Models.Methods /// /// Response expected from an . /// - public class NFTSellOffers //todo rename to NFTSellOffersResponse extends BaseResponse + public class NFTSellOffers : BaseMethodResult//todo rename to NFTSellOffersResponse extends BaseResponse { /// @@ -28,7 +28,7 @@ public class NFTSellOffers //todo rename to NFTSellOffersResponse extends BaseRe public string TokenID { get; set; } } - public class NFTOffer: IDestination + public class NFTOffer : BaseMethodResult, IDestination { [JsonPropertyName("amount")] [JsonConverter(typeof(CurrencyConverter))] diff --git a/Xrpl/Models/Methods/NoRippleCheck.cs b/Xrpl/Models/Methods/NoRippleCheck.cs index 9c82514e..65e01a84 100644 --- a/Xrpl/Models/Methods/NoRippleCheck.cs +++ b/Xrpl/Models/Methods/NoRippleCheck.cs @@ -25,10 +25,18 @@ public enum RoleType /// /// Response expected by a . /// - public class NoRippleCheck //todo rename to NoRippleCheckResponse extends BaseResponse + public class NoRippleCheck : BaseMethodResult//todo rename to NoRippleCheckResponse extends BaseResponse { + /// + /// The ledger index of the current in-progress ledger, if the resolved ledger is open. + /// + /// + /// rippled's shared lookupLedger helper (RPCLedgerHelpers.cpp) sets this only in the + /// else branch of if (!ledger->open()) — a closed/validated ledger sends + /// ledger_hash/ledger_index instead and omits this member entirely. + /// [JsonPropertyName("ledger_current_index")] - public uint LedgerCurrentIndex { get; set; } + public uint? LedgerCurrentIndex { get; set; } /// /// Array of strings with human-readable descriptions of the problems.
diff --git a/Xrpl/Models/Methods/PathFind.cs b/Xrpl/Models/Methods/PathFind.cs index 5fab7b6b..06159b31 100644 --- a/Xrpl/Models/Methods/PathFind.cs +++ b/Xrpl/Models/Methods/PathFind.cs @@ -12,20 +12,20 @@ namespace Xrpl.Models.Methods /// Each element in the alternatives array represents a path from one possible /// source currency to the destination account and currency. ///
- public class PathAlternative + public class PathAlternative : BaseMethodResult { /// /// Array of arrays of objects defining payment paths. /// [JsonPropertyName("paths_computed")] - public List> PathsComputed { get; set; } + public List> PathsComputed { get; set; } /// /// (Deprecated) Array of arrays of objects defining canonical payment paths.
/// May be present in server responses but should be disregarded. ///
[JsonPropertyName("paths_canonical")] - public List> PathsCanonical { get; set; } + public List> PathsCanonical { get; set; } /// /// Currency Amount that the source would have to send along this path @@ -47,7 +47,7 @@ public class PathAlternative /// /// Response expected from a path_find create, close, or status request. /// - public class PathFindResponse + public class PathFindResponse : BaseMethodResult { /// /// Array of objects with suggested paths to take.
@@ -161,7 +161,7 @@ public PathFindCreateRequest(string sourceAccount, string destinationAccount, Cu /// or to check the overall cost to make a payment along a certain path. ///
[JsonPropertyName("paths")] - public List> Paths { get; set; } + public List> Paths { get; set; } } /// diff --git a/Xrpl/Models/Methods/RipplePathFind.cs b/Xrpl/Models/Methods/RipplePathFind.cs index b8cb606a..f1764c3e 100644 --- a/Xrpl/Models/Methods/RipplePathFind.cs +++ b/Xrpl/Models/Methods/RipplePathFind.cs @@ -102,7 +102,7 @@ public RipplePathFindRequest(string sourceAccount, string destinationAccount, Cu /// /// Response expected from a . /// - public class RipplePathFindResponse + public class RipplePathFindResponse : BaseMethodResult { /// /// Array of objects with possible paths to take.
diff --git a/Xrpl/Models/Methods/ServerDefinitions.cs b/Xrpl/Models/Methods/ServerDefinitions.cs index d6547187..16faf6a6 100644 --- a/Xrpl/Models/Methods/ServerDefinitions.cs +++ b/Xrpl/Models/Methods/ServerDefinitions.cs @@ -29,7 +29,7 @@ public ServerDefinitionsRequest() /// /// Response expected from a . /// - public class ServerDefinitionsResponse + public class ServerDefinitionsResponse : BaseMethodResult { /// /// An array of field definitions used in binary serialization. diff --git a/Xrpl/Models/Methods/ServerFeatures.cs b/Xrpl/Models/Methods/ServerFeatures.cs index 2ebcb752..1d8683ff 100644 --- a/Xrpl/Models/Methods/ServerFeatures.cs +++ b/Xrpl/Models/Methods/ServerFeatures.cs @@ -25,9 +25,16 @@ public class ServerFeatures /// /// The ledger index of the ledger that was closed. /// + /// + /// rippled's feature handler (Feature.cpp) never writes ledger_hash, + /// ledger_index or validated at all - unlike most RPC commands it does not call + /// the shared lookupLedger helper, so this member is unconditionally absent from every + /// real response. + /// [JsonPropertyName("ledger_index")] - public ulong LedgerIndex { get; set; } - public bool Validated { get; set; } + public ulong? LedgerIndex { get; set; } + /// See remarks - the feature handler never emits it. + public bool? Validated { get; set; } /// /// Returns features that are currently in voting state diff --git a/Xrpl/Models/Methods/ServerInfo.cs b/Xrpl/Models/Methods/ServerInfo.cs index d599b0cc..8adbc46e 100644 --- a/Xrpl/Models/Methods/ServerInfo.cs +++ b/Xrpl/Models/Methods/ServerInfo.cs @@ -68,13 +68,13 @@ public enum ServerStateInner Proposing } - public class ServerInfo //todo rename to ServerInfoResponse extends BaseResponse + public class ServerInfo : BaseMethodResult//todo rename to ServerInfoResponse extends BaseResponse { [JsonPropertyName("info")] public Info Info { get; set; } } - public class Info + public class Info : BaseMethodResult { /// /// The version number of the running rippled version. @@ -205,16 +205,145 @@ public class Info [JsonConverter(typeof(NumberOrStringConverter))] public string ValidatorListExpires { get; set; } - //todo not found fields - amendment_blocked?: boolean, closed_ledger?:, jq_trans_overflow: string, load_factor_local?: number, load_factor_cluster?: number + /// + /// How long it took this server to reach a synchronised state after starting, in + /// microseconds. A string, as the node sends it. + /// + [JsonPropertyName("initial_sync_duration_us")] + public string InitialSyncDurationUs { get; set; } + + /// + /// How many times this server's job queue has overflowed since it started. + /// + /// + /// Declared as a string because that is what the node sends - the same field on + /// server_state is already modelled that way. + /// + [JsonPropertyName("jq_trans_overflow")] + public string JqTransOverflow { get; set; } + + /// + /// How many peers this server has disconnected since it started. + /// + [JsonPropertyName("peer_disconnects")] + public string PeerDisconnects { get; set; } + + /// + /// How many peers this server has disconnected for exceeding a resource limit. + /// + [JsonPropertyName("peer_disconnects_resources")] + public string PeerDisconnectsResources { get; set; } + + /// + /// How long the server has been in its current , in microseconds. + /// + /// + /// A string here, although ServerState.State declares the same field as a number. + /// Measured against a node rather than assumed: server_info sends + /// "25380868", quoted. + /// + [JsonPropertyName("server_state_duration_us")] + public string ServerStateDurationUs { get; set; } + + /// + /// The server's current time in UTC, as a human-readable string. + /// + [JsonPropertyName("time")] + public string Time { get; set; } + + /// + /// The rough size of this server's configured node, e.g. tiny, small, + /// huge. + /// + [JsonPropertyName("node_size")] + public string NodeSize { get; set; } + + /// + /// Which source revision this server was built from. + /// + /// + /// Not sent by every build - absent leaves this null rather than raising. + /// + [JsonPropertyName("git")] + public GitInfo Git { get; set; } + + /// + /// The state of the published validator list this server is following. + /// + /// + /// Supersedes , which modern rippled does not send. + /// + [JsonPropertyName("validator_list")] + public ValidatorListInfo ValidatorList { get; set; } + + /// + /// The ports this server is listening on, and what speaks on each. + /// + [JsonPropertyName("ports")] + public List Ports { get; set; } + + //todo not found fields - amendment_blocked?: boolean, closed_ledger?:, load_factor_local?: number, load_factor_cluster?: number //load_factor_fee_escalation?: number, load_factor_fee_queue?: number, load_factor_server?: number, network_ledger?: 'waiting' - // server_state_duration_us: number, time: string, + } + + /// + /// Which source revision a server was built from. + /// + public class GitInfo : BaseMethodResult + { + /// The branch the build came from. + [JsonPropertyName("branch")] + public string Branch { get; set; } + + /// The commit the build came from. + [JsonPropertyName("hash")] + public string Hash { get; set; } + } + + /// + /// The state of the published validator list a server is following. + /// + public class ValidatorListInfo : BaseMethodResult + { + /// How many validator lists this server has loaded. + [JsonPropertyName("count")] + public int? Count { get; set; } + + /// + /// When the list expires, in UTC - or unknown before a list is loaded, or + /// never for a static configuration. + /// + [JsonPropertyName("expiration")] + public string Expiration { get; set; } + + /// Whether the list is current, expired, or unknown. + [JsonPropertyName("status")] + public string Status { get; set; } + } + + /// + /// A port a server listens on, and what speaks on it. + /// + public class ServerPort : BaseMethodResult + { + /// + /// The port number. A string, as the node sends it. + /// + [JsonPropertyName("port")] + public string Port { get; set; } + + /// + /// The protocols served on this port, e.g. http, ws, peer. + /// + [JsonPropertyName("protocol")] + public List Protocol { get; set; } } /// /// Information about the last time the server closed a ledger, /// including the amount of time it took to reach a consensus and the number of trusted validators participating. /// - public class LastClose + public class LastClose : BaseMethodResult { /// /// The amount of time it took to reach a consensus on the most recently validated ledger version, in seconds. @@ -233,7 +362,7 @@ public class LastClose /// /// (Admin only) Detailed information about the current load state of the server. /// - public class JobType + public class JobType : BaseMethodResult { [JsonPropertyName("job_type")] public string JobTypeDescription { get; set; } @@ -251,7 +380,7 @@ public class JobType /// /// (Admin only) Detailed information about the current load state of the server. /// - public class Load + public class Load : BaseMethodResult { /// /// (Admin only) Information about the rate of different types of jobs the server is doing and how much time it spends on each. @@ -266,7 +395,7 @@ public class Load public int Threads { get; set; } } - public class AccountingStateInfo + public class AccountingStateInfo : BaseMethodResult { [JsonPropertyName("duration_us")] public string DurationUs { get; set; } @@ -293,7 +422,7 @@ public TimeSpan Duration /// A map of various server states with information about the time the server spends in each.
/// This can be useful for tracking the long-term health of your server's connectivity to the network. ///
- public class AccountingStateSummary + public class AccountingStateSummary : BaseMethodResult { [JsonPropertyName("connected")] public AccountingStateInfo Connected { get; set; } @@ -320,7 +449,7 @@ public class AccountingStateSummary /// /// Information about the most recent fully-validated ledger. /// - public class ValidatedLedger + public class ValidatedLedger : BaseMethodResult { /// /// The time since the ledger was closed, in seconds. diff --git a/Xrpl/Models/Methods/ServerState.cs b/Xrpl/Models/Methods/ServerState.cs index bd13066e..b210d131 100644 --- a/Xrpl/Models/Methods/ServerState.cs +++ b/Xrpl/Models/Methods/ServerState.cs @@ -18,13 +18,13 @@ public ServerStateRequest() } } - public class ServerState //todo rename to ServerInfoResponse extends BaseResponse + public class ServerState : BaseMethodResult//todo rename to ServerInfoResponse extends BaseResponse { [JsonPropertyName("state")] public State State { get; set; } } - public class State + public class State : BaseMethodResult { /// /// The version number of the running rippled version. @@ -169,7 +169,7 @@ public class State /// /// Information about the most recent fully-validated ledger. /// - public class StateLedger + public class StateLedger : BaseMethodResult { /// /// The time since the ledger was closed, in seconds. @@ -195,17 +195,17 @@ public class StateLedger public string Hash { get; set; } /// - /// Minimum amount of XRP (not drops) necessary for every account to.
+ /// (May be omitted) Minimum amount of XRP (not drops) necessary for every account to.
/// Keep in reserve. ///
[JsonPropertyName("reserve_base")] - public uint ReserveBase { get; set; } + public uint? ReserveBase { get; set; } /// - /// Amount of XRP (not drops) added to the account reserve for each object an account owns in the ledger. + /// (May be omitted) Amount of XRP (not drops) added to the account reserve for each object an account owns in the ledger. /// [JsonPropertyName("reserve_inc")] - public uint ReserveInc { get; set; } + public uint? ReserveInc { get; set; } /// /// The ledger index of the latest validated ledger. diff --git a/Xrpl/Models/Methods/SimulateRequest.cs b/Xrpl/Models/Methods/SimulateRequest.cs index dfb1ad75..72013e45 100644 --- a/Xrpl/Models/Methods/SimulateRequest.cs +++ b/Xrpl/Models/Methods/SimulateRequest.cs @@ -40,7 +40,7 @@ public SimulateRequest() [JsonPropertyName("binary")] public bool? Binary { get; set; } } -public class SimulateResponse +public class SimulateResponse : BaseMethodResult { [JsonPropertyName("applied")] public bool Applied { get; set; } diff --git a/Xrpl/Models/Methods/Subscribe.cs b/Xrpl/Models/Methods/Subscribe.cs index 9f5c2de9..7260840b 100644 --- a/Xrpl/Models/Methods/Subscribe.cs +++ b/Xrpl/Models/Methods/Subscribe.cs @@ -1,7 +1,8 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; using System; using System.Collections.Generic; +using System.Text.Json; using Xrpl.Models.Enums; using Xrpl.Models.Subscriptions; @@ -166,14 +167,16 @@ public SubscribeRequest() /// The message identifies the ledger and provides some information about its contents. /// /// - public class LedgerStream + /// + /// reads as once + /// this is deserialized off a real ledgerClosed message, the same way every property + /// here does - there used to be a constructor that stamped it unconditionally, which meant an + /// instance built by hand (rather than through the deserializer) reported a type it was never + /// actually given. Removed once became nullable, so absence now + /// reads as absence instead of being papered over. + /// + public class LedgerStream : BaseStream { - /// - /// ledgerClosed indicates this is from the ledger stream - /// - [JsonPropertyName("type")] - [JsonConverter(typeof(JsonStringEnumConverter))] - public ResponseStreamType Type = ResponseStreamType.ledgerClosed; /// /// The reference transaction cost as of this ledger version, in drops of XRP.
/// If this ledger version includes a SetFee pseudo-transaction the new transaction cost applies starting with the following ledger version. @@ -183,8 +186,14 @@ public class LedgerStream /// /// The reference transaction cost in "fee units". /// + /// + /// rippled's NetworkOpsImp::pubLedger emits this only when the XRPFees + /// amendment is NOT enabled. That amendment is active on mainnet, so a current node never + /// sends the field at all - modelling it as non-nullable fabricated fee_ref: 0 into + /// every round-trip. + /// [JsonPropertyName("fee_ref")] - public uint FeeRef { get; set; } + public uint? FeeRef { get; set; } /// /// The identifying hash of the ledger version that was closed. /// @@ -230,19 +239,34 @@ public class LedgerStream /// /// This response mirrors the LedgerStream, except it does NOT include the 'type' nor 'txn_count' fields. /// + /// + /// Models the subscribe command's own synchronous reply when the client subscribes to + /// the ledger stream (rippled NetworkOpsImp::subLedger), a different code path + /// from the async ledgerClosed push that models. There, + /// ledger_index/reserve_base/reserve_inc (with ledger_hash, + /// ledger_time and fee_base) are all gated on + /// ledgerMaster_.getValidatedLedger() returning non-null - if the node has no validated + /// ledger yet, the initial reply omits every one of them. + /// public class LedgerStreamResponse : BaseResponse { /// /// The reference transaction cost as of this ledger version, in drops of XRP.
/// If this ledger version includes a SetFee pseudo-transaction the new transaction cost applies starting with the following ledger version. ///
+ /// See remarks - same conditional gate. [JsonPropertyName("fee_base")] - public uint FeeBase { get; set; } + public uint? FeeBase { get; set; } /// /// The reference transaction cost in "fee units". /// + /// + /// Doubly conditional in NetworkOpsImp::subLedger: inside the + /// getValidatedLedger() gate AND only when the XRPFees amendment is disabled. + /// Since that amendment is active on mainnet, a current node never sends this field. + /// [JsonPropertyName("fee_ref")] - public uint FeeRef { get; set; } + public uint? FeeRef { get; set; } /// /// The identifying hash of the ledger version that was closed. /// @@ -251,30 +275,44 @@ public class LedgerStreamResponse : BaseResponse /// /// The ledger index of the ledger that was closed. /// + /// + /// rippled's NetworkOpsImp::subLedger only sets this (with ledger_hash, + /// reserve_base and reserve_inc below) when + /// ledgerMaster_.getValidatedLedger() returns a ledger; a node with no validated + /// ledger yet omits it from the initial subscribe reply. + /// [JsonPropertyName("ledger_index")] - public ulong LedgerIndex { get; set; } + public ulong? LedgerIndex { get; set; } /// /// The time this ledger was closed, in seconds since the Ripple Epoch /// + /// See remarks - same conditional gate. [JsonPropertyName("ledger_time")] - public ulong LedgerTime { get; set; } + public ulong? LedgerTime { get; set; } /// /// The minimum reserve, in drops of XRP, that is required for an account.
/// If this ledger version includes a SetFee pseudo-transaction the new base reserve applies starting with the following ledger version. ///
+ /// See remarks - same conditional gate. [JsonPropertyName("reserve_base")] - public uint ReserveBase { get; set; } + public uint? ReserveBase { get; set; } /// /// The owner reserve for each object an account owns in the ledger, in drops of XRP.
/// If the ledger includes a SetFee pseudo-transaction the new owner reserve applies after this ledger. ///
+ /// See remarks - same conditional gate. [JsonPropertyName("reserve_inc")] - public uint ReserveInc { get; set; } + public uint? ReserveInc { get; set; } /// /// Number of new transactions included in this ledger version. /// + /// + /// Never sent on this path at all: NetworkOpsImp::subLedger emits no + /// txn_count, which is what this type's own summary says. Only the asynchronous + /// ledgerClosed push () carries it. + /// [JsonPropertyName("txn_count")] - public uint TxnCount { get; set; } + public uint? TxnCount { get; set; } /// /// (May be omitted) Range of ledgers that the server has available.
/// This may be a disjoint sequence such as 24900901-24900984,24901116-24901158.
@@ -283,6 +321,16 @@ public class LedgerStreamResponse : BaseResponse [JsonPropertyName("validated_ledgers")] public string ValidatedLedgers { get; set; } + /// + /// Members of the subscribe reply's result that no declared property claims + /// - for example network_id, which NetworkOpsImp::subLedger sets unconditionally + /// (NetworkOPs.cpp) alongside ledger_hash/ledger_index. + /// carries no capture of its own (its id/result members are byte-range slices, + /// not parsed values), so this class declares one directly rather than inheriting it - + /// mirroring for the RPC-result family. + /// + [JsonExtensionData] + public Dictionary UnknownFields { get; set; } } /// @@ -333,10 +381,14 @@ public class ValidationStream : BaseStream /// /// The ledger index of the ledger that was closed. /// + /// + /// rippled's NetworkOpsImp::pubValidation only sets this when the underlying + /// STValidation carries the optional sfLedgerSequence field. + /// [JsonPropertyName("ledger_index")] - public ulong LedgerIndex { get; set; } + public ulong? LedgerIndex { get; set; } /// - /// (May be omitted) The local load-scaled transaction cost this validator is currently enforcing, in fee units. + /// (May be omitted) The local load-scaled transaction cost this validator is currently enforcing, in fee units. /// [JsonPropertyName("load_fee")] public uint? LoadFee { get; set; } diff --git a/Xrpl/Models/Methods/TransactionEntry.cs b/Xrpl/Models/Methods/TransactionEntry.cs index 40a75f1b..c75e735a 100644 --- a/Xrpl/Models/Methods/TransactionEntry.cs +++ b/Xrpl/Models/Methods/TransactionEntry.cs @@ -27,7 +27,7 @@ public TransactionEntryRequest() /// /// Response expected from a . /// - public class TransactionEntryResponse + public class TransactionEntryResponse : BaseMethodResult { /// /// The transaction object in JSON format. diff --git a/Xrpl/Models/Methods/VaultInfo.cs b/Xrpl/Models/Methods/VaultInfo.cs index 7d695b0c..99fe5a77 100644 --- a/Xrpl/Models/Methods/VaultInfo.cs +++ b/Xrpl/Models/Methods/VaultInfo.cs @@ -27,7 +27,7 @@ public VaultInfoRequest() /// /// Response expected from a . /// - public class VaultInfoResponse + public class VaultInfoResponse : BaseMethodResult { /// /// The vault ledger object. diff --git a/Xrpl/Models/Subscriptions/BaseResponse.cs b/Xrpl/Models/Subscriptions/BaseResponse.cs index f8bdc29b..c80a19bd 100644 --- a/Xrpl/Models/Subscriptions/BaseResponse.cs +++ b/Xrpl/Models/Subscriptions/BaseResponse.cs @@ -1,8 +1,10 @@ -using System.Text.Json.Serialization; +using System.ComponentModel; +using System.Text.Json.Serialization; using System; using System.Collections.Generic; +using Xrpl.Client.Json; using Xrpl.Client.Json.Converters; using Xrpl.Models.Transactions; @@ -13,11 +15,49 @@ namespace Xrpl.Models.Subscriptions { public class BaseResponse { + // Not [JsonIgnore]: System.Text.Json never serializes private fields, so the attribute + // would be a no-op that misleads a reader into thinking it is load-bearing here. + // Internal, not private: ErrorResponse adds its own slice-based member (RequestSlice) + // over the same frame, and needs this to build RawRequest the same way RawResult is built + // here. + internal byte[]? _frame; + + private JsonSlice _idSlice; + + private JsonSlice _resultSlice; + /// - /// (WebSocket only) ID provided in the request that prompted this response + /// (WebSocket only) ID provided in the request that prompted this response. /// + /// + /// Deliberately not the parsed id: binding it to made + /// System.Text.Json build a whose pooled backing + /// array is never returned, and it was then formatted back to a string on every response + /// just to be matched against a pending request's . Recording + /// bounds costs nothing, and parses the Guid straight out of + /// the bytes. + /// [JsonPropertyName("id")] - public object? Id { get; set; } + [JsonConverter(typeof(JsonSliceConverter))] + [JsonInclude] + internal JsonSlice IdSlice + { + // Set-only, like the DeliverMax alias on PaymentResponse: System.Text.Json fills it on + // read but never asks for it on write, so the converter's Write - which refuses, because + // an envelope rebuilt from bounds would be a different document - is never reached. + // Without this, serializing any envelope model threw, including the public subscription + // types a consumer may well log. + set => _idSlice = value; + } + + /// + /// The id member exactly as the node sent it. + /// + [JsonIgnore] + public RawJson RawId => + _frame is null || _idSlice.IsEmpty + ? default + : RawJson.Trusted(_frame, _idSlice.Offset, _idSlice.Length); /// /// "error" if the request caused an error @@ -31,11 +71,86 @@ public class BaseResponse [JsonPropertyName("type")] public string Type { get; set; } /// - /// (WebSocket only) The value success indicates the request was successfully received and understood by the server.
- /// Some client libraries omit this field on success. + /// Where the result member sits inside the frame passed to + /// . ///
+ /// + /// Deliberately not the parsed result: binding it to made + /// System.Text.Json build a whose pooled backing + /// array is never returned, and the member was then parsed a second time to reach the + /// requested type. Recording bounds costs nothing and leaves exactly one parse, cut + /// straight from the frame. + /// [JsonPropertyName("result")] - public object Result { get; set; } + [JsonConverter(typeof(JsonSliceConverter))] + [JsonInclude] + internal JsonSlice ResultSlice + { + set => _resultSlice = value; + } + + /// + /// Pairs this envelope with the frame it was read from. + /// + /// + /// One call instead of a settable property, so the bounds are checked against the buffer + /// once, where the two meet — a frame that does not match a recorded slice is rejected + /// here rather than lazily, inside a consumer's read of or + /// . Internal on purpose: the bounds are only meaningful for a reader + /// that covered one contiguous buffer, which the Stream overloads of System.Text.Json do + /// not, and keeping this unreachable disarms that path by construction. Virtual so + /// can check its own RequestSlice the same way before + /// deferring here. + /// + /// + /// is . + /// + /// + /// is too short for a recorded slice. + /// + internal virtual void AttachFrame(byte[] frame) + { + if (frame is null) + { + throw new ArgumentNullException(nameof(frame)); + } + + ValidateSliceFitsFrame(_resultSlice, frame); + ValidateSliceFitsFrame(_idSlice, frame); + + _frame = frame; + } + + /// + /// Checks that lies inside . Shared so + /// every slice-based member — here and in — is checked the + /// same way, before is set for any of them. + /// + /// does not fit. + internal static void ValidateSliceFitsFrame(JsonSlice slice, byte[] frame) + { + // Unsigned, matching the check in the RawJson constructor: a negative Offset cast to + // uint becomes huge and trips the first comparison, instead of making the subtraction + // below go negative and comparing wrong. + if (!slice.IsEmpty + && ((uint)slice.Offset > (uint)frame.Length + || (uint)slice.Length > (uint)(frame.Length - slice.Offset))) + { + throw new ArgumentException( + $"Frame of {frame.Length} bytes does not contain the recorded slice at " + + $"[{slice.Offset}, {slice.Offset + (long)slice.Length}).", + nameof(frame)); + } + } + + /// + /// The result member exactly as the node sent it. + /// + [JsonIgnore] + public RawJson RawResult => + _frame is null || _resultSlice.IsEmpty + ? default + : RawJson.Trusted(_frame, _resultSlice.Offset, _resultSlice.Length); /// /// (May be omitted) If this field is provided, the value is the string load.
/// This means the client is approaching the rate limiting threshold where the server will disconnect this client. diff --git a/Xrpl/Models/Subscriptions/BaseStream.cs b/Xrpl/Models/Subscriptions/BaseStream.cs index 48431337..1adc35ef 100644 --- a/Xrpl/Models/Subscriptions/BaseStream.cs +++ b/Xrpl/Models/Subscriptions/BaseStream.cs @@ -1,15 +1,99 @@ -using System.Text.Json.Serialization; +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Xrpl.Client.Json; namespace Xrpl.Models.Subscriptions { public class BaseStream { + // Not [JsonIgnore]: System.Text.Json never serializes private fields, so the attribute + // would be a no-op that misleads a reader into thinking it is load-bearing here. + // Internal, not private: TransactionStream.RawTransaction reads this directly to build its + // own RawJson window over the tx_json/transaction slice, the same way Raw does below. + internal byte[]? _frame; + + private JsonSlice _documentSlice; + /// /// consensusPhase indicates this is from the consensus stream
/// consensusPhase - type ///
+ /// + /// Nullable because absence is meaningful: an event built by hand rather than deserialized + /// off the wire never had a type member to read. A non-nullable enum defaults to + /// (0), which JsonSerializer.Serialize then + /// wrote back out as the literal member "type":"UNKNOWN" - a value the node never + /// sent, fabricated purely because the CLR type could not represent "no type at all". + /// [JsonPropertyName("type")] [JsonConverter(typeof(JsonStringEnumConverter))] - public ResponseStreamType Type { get; set; } + public ResponseStreamType? Type { get; set; } + + /// + /// This event exactly as the node sent it. + /// + /// + /// Unlike a query response, a stream message carries no result envelope to slice a + /// member out of — the frame passed to already is the + /// event, so this spans the whole of it via . + /// Empty when the event was never paired with a frame (built by hand, or deserialized + /// through a bare JsonSerializer.Deserialize call rather than the stream pipeline). + /// + [JsonIgnore] + public RawJson Raw => + _frame is null || _documentSlice.IsEmpty + ? default + : RawJson.Trusted(_frame, _documentSlice.Offset, _documentSlice.Length); + + /// + /// Pairs this event with the frame it was read from. + /// + /// + /// Virtual so can compute its own tx_json/transaction + /// slice from the same frame before deferring here. Unlike + /// , which validates a slice that arrived + /// through deserialization before deferring to , + /// there is nothing to validate on this path: derives its + /// slice by scanning this same frame directly, via + /// , so slice and frame cannot + /// disagree. + /// + /// is . + internal virtual void AttachFrame(byte[] frame) + { + if (frame is null) + { + throw new ArgumentNullException(nameof(frame)); + } + + _documentSlice = JsonSlice.OfDocument(frame); + _frame = frame; + } + + /// + /// Members of this stream event that no declared property on the concrete subclass claims + /// - rippled sends several fields unconditionally on every push of a given stream + /// (network_id on ledgerClosed/the ledger stream's subscribe reply, + /// ctid and the account_history_* trio on transaction events) that no + /// model here declared a property for, and they vanished on the way to a caller. Declared + /// on the shared base, mirroring , + /// and + /// for their own families, so every + /// stream type - , , + /// - picks it up without repeating the attribute per class. + /// + /// + /// This is not a substitute for : values here have already gone through + /// JSON parsing (numbers, strings, nested objects as ), while + /// is the exact bytes the node sent. Use when + /// byte-for-byte fidelity matters; use this when a caller just needs to read a field the + /// model does not yet declare - see 's + /// remarks for the retention cost of doing so. + /// + [JsonExtensionData] + public Dictionary UnknownFields { get; set; } } } \ No newline at end of file diff --git a/Xrpl/Models/Subscriptions/BookChangesStream.cs b/Xrpl/Models/Subscriptions/BookChangesStream.cs index c58b163d..6d7e7b06 100644 --- a/Xrpl/Models/Subscriptions/BookChangesStream.cs +++ b/Xrpl/Models/Subscriptions/BookChangesStream.cs @@ -141,7 +141,8 @@ public class BookChange public string Close { get; set; } /// - /// Parses a book_changes currency string into an . + /// Parses a book_changes currency string into an + /// . /// Handles "XRP_drops" and "issuer/currency_hex" formats. /// private static Common.Common.IssuedCurrency ParseBookChangeCurrency(string raw) diff --git a/Xrpl/Models/Subscriptions/ErrorResponse.cs b/Xrpl/Models/Subscriptions/ErrorResponse.cs index b89f968e..618da606 100644 --- a/Xrpl/Models/Subscriptions/ErrorResponse.cs +++ b/Xrpl/Models/Subscriptions/ErrorResponse.cs @@ -1,4 +1,9 @@ -using System.Text.Json.Serialization; +using System; +using System.ComponentModel; +using System.Text.Json.Serialization; + +using Xrpl.Client.Json; +using Xrpl.Client.Json.Converters; //https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/src/models/methods/baseMethod.ts //https://xrpl.org/error-formatting.html#error-formatting @@ -26,10 +31,53 @@ public class ErrorResponse : BaseResponse public string? ErrorException { get; set; } /// - /// A copy of the request that prompted this error, in JSON format.
- /// Caution: If the request contained any secrets, they are copied here! + /// Where the request member sits inside the frame passed to + /// . ///
+ /// + /// Deliberately not the parsed request: binding it to made + /// System.Text.Json build a whose pooled backing + /// array is never returned, on every error response - and Sugar/Submit.cs hits this + /// branch on every poll of an unconfirmed transaction. Recording bounds costs nothing. + /// [JsonPropertyName("request")] + [JsonConverter(typeof(JsonSliceConverter))] + [JsonInclude] + internal JsonSlice RequestSlice + { + set => _requestSlice = value; + } + + private JsonSlice _requestSlice; + + /// + /// A copy of the request that prompted this error, exactly as the node echoed it back. + /// + /// + /// Caution: if the original request carried secrets, they are echoed here — same warning as + /// the field it replaces. + /// + [JsonIgnore] + public RawJson RawRequest => + _frame is null || _requestSlice.IsEmpty + ? default + : RawJson.Trusted(_frame, _requestSlice.Offset, _requestSlice.Length); + + /// + /// + /// Checks against the frame before deferring to + /// for result and id, so a frame + /// that does not fit any of the three recorded slices is rejected here rather than lazily, + /// inside a consumer's read of . + /// + internal override void AttachFrame(byte[] frame) + { + if (frame is null) + { + throw new ArgumentNullException(nameof(frame)); + } - public object Request { get; set; } + ValidateSliceFitsFrame(_requestSlice, frame); + base.AttachFrame(frame); + } } \ No newline at end of file diff --git a/Xrpl/Models/Subscriptions/TransactionStream.cs b/Xrpl/Models/Subscriptions/TransactionStream.cs index 2f66a43e..628de60d 100644 --- a/Xrpl/Models/Subscriptions/TransactionStream.cs +++ b/Xrpl/Models/Subscriptions/TransactionStream.cs @@ -1,6 +1,7 @@ using System; using System.Text.Json.Serialization; +using Xrpl.Client.Json; using Xrpl.Models.Methods; using Xrpl.Models.Transactions; @@ -35,6 +36,55 @@ public class TransactionStream : BaseStream, IAccountTransaction [JsonPropertyName("status")] public string Status { get; set; } + + /// + /// The compact transaction identifier of this transaction, when rippled reports one. + /// + /// + /// rippled's NetworkOpsImp::transJson writes this at the top level of the stream + /// event (jvObj[jss::ctid] in NetworkOPs.cpp), not inside tx_json/transaction + /// - unlike account_tx, which nests it inside the transaction envelope and lands on + /// instead. A dedicated property rather than + /// leaving it to : a wallet identifying "which transaction is + /// this" needs it typed, the same way is, not fished out of a dictionary. + /// + [JsonPropertyName("ctid")] + public string Ctid { get; set; } + + /// + /// Position of this transaction within an account_history subscription, when the + /// event comes from one. + /// + /// + /// Signed on purpose. rippled counts forward from zero while streaming new transactions + /// (forwardTxIndex++, a uint32) and counts *down* through the same zero while + /// backfilling history (txHistoryIndex--, NetworkOPs.cpp), so a backfilled event + /// carries a negative index. + /// + /// Declared rather than left to along with the two + /// flags below: rippled sends all three on every event of such a subscription, and capture + /// costs about 464 B per member because each unknown value is parsed into its own + /// . Measured at ~796 B per event for the three + /// together - paid on every transaction a wallet receives, which is the one path where + /// that is least affordable. + /// + [JsonPropertyName("account_history_tx_index")] + public long? AccountHistoryTxIndex { get; set; } + + /// + /// Present and true when this transaction is the last one of its ledger within an + /// account_history stream - the marker a consumer batches on. + /// + [JsonPropertyName("account_history_boundary")] + public bool? AccountHistoryBoundary { get; set; } + + /// + /// Present and true on the earliest transaction that ever touched the subscribed + /// account, which is how a consumer knows the backfill has reached the end. + /// + [JsonPropertyName("account_history_tx_first")] + public bool? AccountHistoryTxFirst { get; set; } + /// /// Numeric transaction response code, if applicable. /// @@ -109,6 +159,60 @@ private TransactionResponse TransactionV1 set => _transaction = value ?? _transaction; } + private JsonSlice _transactionSlice; + + /// + /// The transaction exactly as the node sent it — tx_json under API v2, + /// transaction under API v1. + /// + /// + /// Empty when this event was never paired with a frame, or the message carried neither + /// envelope (a stream message reporting neither tx_json nor transaction). + /// + [JsonIgnore] + public RawJson RawTransaction => + _frame is null || _transactionSlice.IsEmpty + ? default + : RawJson.Trusted(_frame, _transactionSlice.Offset, _transactionSlice.Length); + + /// + /// + /// and its API v1 alias already claim tx_json and + /// transaction - System.Text.Json rejects a second member bound to a name another + /// member already owns, so cannot be filled the way + /// is, through a converter-backed property. Instead + /// this scans the frame directly with , after the + /// object graph ( included) has already been built from it - + /// finding no more than what deserialization itself just read. + /// + internal override void AttachFrame(byte[] frame) + { + if (frame is null) + { + throw new ArgumentNullException(nameof(frame)); + } + + JsonSlice txJson = JsonSlice.FindTopLevelMember(frame, "tx_json"u8, ignoringJsonNull: true); + JsonSlice legacy = JsonSlice.FindTopLevelMember(frame, "transaction"u8, ignoringJsonNull: true); + + // Whichever envelope sits later in the frame wins, because that is what the typed + // Transaction ends up holding: its two setters both do `value ?? _transaction` and run + // in document order, so the last non-null one assigned is the one that survives. + // Preferring tx_json unconditionally would let RawTransaction show the caller one + // transaction while Transaction carried another - the same show-one/sign-another split + // that duplicate keys were already fixed for in JsonSlice.FindTopLevelMember. rippled + // never sends both (NetworkOPs.cpp transJson moves transaction to tx_json under API v2 + // rather than adding it), but the frame arrives over the network through arbitrary + // infrastructure, so the two views must not be able to disagree. + _transactionSlice = + txJson.IsEmpty ? legacy + : legacy.IsEmpty ? txJson + : legacy.Offset > txJson.Offset ? legacy : txJson; + + base.AttachFrame(frame); + } + + /// /// If true, this transaction is included in a validated ledger and its outcome is final.
/// Responses from the transaction stream should always be validated. diff --git a/Xrpl/Models/Transactions/AMMCreate.cs b/Xrpl/Models/Transactions/AMMCreate.cs index 6044b10e..f33f3667 100644 --- a/Xrpl/Models/Transactions/AMMCreate.cs +++ b/Xrpl/Models/Transactions/AMMCreate.cs @@ -30,7 +30,7 @@ public AMMCreate() [JsonConverter(typeof(CurrencyConverter))] public Xrpl.Models.Common.Currency Amount2 { get; set; } /// - public uint TradingFee { get; set; } + public uint? TradingFee { get; set; } } /// /// AMMCreate is used to create AccountRoot and the corresponding AMM ledger entries. @@ -54,7 +54,7 @@ public interface IAMMCreate : ITransactionCommon /// A value of 1 is equivalent to 1/10 bps or 0.001%, allowing trading fee /// between 0% and 1%. /// - public uint TradingFee { get; set; } + public uint? TradingFee { get; set; } } /// @@ -69,7 +69,7 @@ public class AMMCreateResponse : TransactionResponse, IAMMCreate [JsonConverter(typeof(CurrencyConverter))] public Currency Amount2 { get; set; } /// - public uint TradingFee { get; set; } + public uint? TradingFee { get; set; } #endregion } diff --git a/Xrpl/Models/Transactions/AMMVote.cs b/Xrpl/Models/Transactions/AMMVote.cs index cf147631..652bb06c 100644 --- a/Xrpl/Models/Transactions/AMMVote.cs +++ b/Xrpl/Models/Transactions/AMMVote.cs @@ -22,7 +22,7 @@ public AMMVote() [JsonConverter(typeof(IssuedCurrencyConverter))] public IssuedCurrency Asset2 { get; set; } /// - public uint TradingFee { get; set; } + public uint? TradingFee { get; set; } } public interface IAMMVote : ITransactionCommon @@ -41,7 +41,7 @@ public interface IAMMVote : ITransactionCommon /// A value of 1 is equivalent to 1/10 bps or 0.001%, allowing trading fee /// between 0% and 1%. This field is required. ///
- public uint TradingFee { get; set; } + public uint? TradingFee { get; set; } } /// @@ -56,7 +56,7 @@ public class AMMVoteResponse : TransactionResponse, IAMMVote [JsonConverter(typeof(IssuedCurrencyConverter))] public IssuedCurrency Asset2 { get; set; } /// - public uint TradingFee { get; set; } + public uint? TradingFee { get; set; } #endregion } diff --git a/Xrpl/Models/Transactions/BaseTransactionResponse.cs b/Xrpl/Models/Transactions/BaseTransactionResponse.cs index 2197a5cb..0312693b 100644 --- a/Xrpl/Models/Transactions/BaseTransactionResponse.cs +++ b/Xrpl/Models/Transactions/BaseTransactionResponse.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Text.Json; using System.Text.Json.Serialization; using Xrpl.Client.Json.Converters; @@ -12,6 +14,31 @@ namespace Xrpl.Models.Transactions ///
public interface IBaseTransactionResponse { + /// + /// The ledger close time represented in ISO 8601 time format. + /// + /// + /// rippled attaches this directly to the transaction object on tx/account_tx + /// responses regardless of API version — API v1 flattens the whole response onto the + /// transaction, and API v2's account_tx nests it inside tx_json alongside + /// ctid (unlike the singular tx method, where it sits beside tx_json + /// instead — see ). + /// + [JsonConverter(typeof(FromStringDateTimeConverter))] + [JsonPropertyName("close_time_iso")] + DateTime? CloseTimeIso { get; set; } + + /// + /// The compact transaction identifier of this transaction, when rippled reports one. + /// + /// + /// New in rippled 1.12.0. rippled nests this inside the transaction object itself on + /// account_tx — see the remark on for why that placement + /// differs from the singular tx method's . + /// + [JsonPropertyName("ctid")] + string? Ctid { get; set; } + /// /// The date/time when this transaction was included in a validated ledger. /// @@ -41,6 +68,15 @@ public interface IBaseTransactionResponse /// public class BaseTransactionResponse : IBaseTransactionResponse { + /// + [JsonConverter(typeof(FromStringDateTimeConverter))] + [JsonPropertyName("close_time_iso")] + public DateTime? CloseTimeIso { get; set; } + + /// + [JsonPropertyName("ctid")] + public string? Ctid { get; set; } + /// [JsonConverter(typeof(RippleDateTimeConverter))] [JsonPropertyName("date")] @@ -59,5 +95,23 @@ public class BaseTransactionResponse : IBaseTransactionResponse [JsonPropertyName("validated")] public bool? Validated { get; set; } + + /// + /// Members of this transaction response that no declared property claims — for example + /// a field a new amendment adds before this SDK models it. Populated on + /// every derived *Response type, and reached whether the response is deserialized directly + /// or through : that + /// converter only picks the concrete .NET type from TransactionType; the field-level + /// read is the ordinary reflection-based deserializer, which is what honors this attribute. + /// + /// + /// This is not a substitute for XrplResponse<T>.Raw: values here have already + /// gone through JSON parsing (numbers, strings, nested objects as ), + /// while Raw is the exact bytes the node sent. Use Raw when byte-for-byte + /// fidelity matters (verifying what a node actually said); use this when a caller just needs + /// to read a field the model does not yet declare. + /// + [JsonExtensionData] + public Dictionary UnknownFields { get; set; } } } diff --git a/Xrpl/Models/Transactions/Common.cs b/Xrpl/Models/Transactions/Common.cs index f55c6b4a..db2c657a 100644 --- a/Xrpl/Models/Transactions/Common.cs +++ b/Xrpl/Models/Transactions/Common.cs @@ -597,18 +597,19 @@ public class NodeInfo : ICreatedNode, IModifiedNode, IDeletedNode [JsonPropertyName("PreviousTxnLgrSeq")] public uint? PreviousTxnLgrSeq { get; set; } /// + /// The content fields of the ledger object, read differently depending on the node type. + /// /// - /// DeletedNode
- /// The content fields of the ledger object immediately before it was deleted.
- /// Which fields are present depends on what type of ledger object was created. + /// + /// DeletedNode: the fields as they were immediately before the object was deleted. + /// Which fields are present depends on what type of ledger object it was. + /// + /// + /// ModifiedNode: the fields after applying any changes from this transaction. Which + /// fields are present depends on the object type, and this omits PreviousTxnID and + /// PreviousTxnLgrSeq even though most types of ledger object carry them. + /// ///
- /// - /// ModifiedNode
- /// The content fields of the ledger object after applying any changes from this transaction.
- /// Which fields are present depends on what type of ledger object was created.
- /// This omits the PreviousTxnID and PreviousTxnLgrSeq fields, even though most types of ledger objects have them. - ///
- ///
public BaseLedgerEntry? FinalFields { get; set; } /// @@ -743,6 +744,18 @@ public interface ITransactionCommon string ToJson(); Dictionary ToDictionary(); } + /// + /// A transaction on its way to a node: built by a caller, signed, submitted. + /// + /// + /// The converter is declared here as well as on the implementing class because + /// System.Text.Json picks one by the declared type: a variable typed as this interface + /// does not inherit the class's attribute, and without it a transaction held in such a variable + /// was written as the interface - every field of its actual type missing, and no exception to + /// say so. It happened to work through , + /// whose converter list makes up for a missing attribute, and only there. + /// + [JsonConverter(typeof(TransactionRequestConverter))] public interface ITransactionRequest : ITransactionCommon { } @@ -750,6 +763,12 @@ public interface ITransactionRequest : ITransactionCommon /// /// Every transaction has the same set of common fields. /// + /// + /// The converter is declared here for the same reason as on : + /// System.Text.Json picks one by the declared type, so without it a transaction held in a + /// variable of this type was written as the interface rather than as what it is. + /// + [JsonConverter(typeof(TransactionResponseConverter))] public interface ITransactionResponse : IBaseTransactionResponse, ITransactionCommon { /// diff --git a/Xrpl/Models/Transactions/DIDSet.cs b/Xrpl/Models/Transactions/DIDSet.cs index 6e64912f..e768485d 100644 --- a/Xrpl/Models/Transactions/DIDSet.cs +++ b/Xrpl/Models/Transactions/DIDSet.cs @@ -75,13 +75,13 @@ public class DIDSetResponse : TransactionResponse, IDIDSet public partial class Validation { + private const int MaxDIDFieldLength = 512; + /// /// Verify the form and type of a DIDSet at runtime. /// /// A DIDSet Transaction. /// When the DIDSet is malformed. - private const int MaxDIDFieldLength = 512; - public static async Task ValidateDIDSet(Dictionary tx) { await Common.ValidateBaseTransaction(tx); diff --git a/Xrpl/Models/Transactions/EnableAmendment.cs b/Xrpl/Models/Transactions/EnableAmendment.cs index 9580d9d7..59d93105 100644 --- a/Xrpl/Models/Transactions/EnableAmendment.cs +++ b/Xrpl/Models/Transactions/EnableAmendment.cs @@ -12,7 +12,7 @@ public EnableAmendment() public string Amendment { get; set; } /// - public uint LedgerSequence { get; set; } + public uint? LedgerSequence { get; set; } } public interface IEnableAmendment : ITransactionCommon @@ -27,7 +27,7 @@ public interface IEnableAmendment : ITransactionCommon /// The ledger index where this pseudo-transaction appears.
/// This distinguishes the pseudo-transaction from other occurrences of the same change. ///
- uint LedgerSequence { get; set; } + uint? LedgerSequence { get; set; } } public class EnableAmendmentResponse : TransactionResponse, IEnableAmendment @@ -35,6 +35,6 @@ public class EnableAmendmentResponse : TransactionResponse, IEnableAmendment /// public string Amendment { get; set; } /// - public uint LedgerSequence { get; set; } + public uint? LedgerSequence { get; set; } } } diff --git a/Xrpl/Models/Transactions/EscrowCancel.cs b/Xrpl/Models/Transactions/EscrowCancel.cs index 977533f8..58676fc1 100644 --- a/Xrpl/Models/Transactions/EscrowCancel.cs +++ b/Xrpl/Models/Transactions/EscrowCancel.cs @@ -20,7 +20,7 @@ public EscrowCancel() public string Owner { get; set; } /// - public uint OfferSequence { get; set; } + public uint? OfferSequence { get; set; } } /// @@ -32,7 +32,7 @@ public interface IEscrowCancel : ITransactionCommon /// Transaction sequence (or Ticket number) of EscrowCreate transaction that.
/// created the escrow to cancel. ///
- uint OfferSequence { get; set; } + uint? OfferSequence { get; set; } /// /// Address of the source account that funded the escrow. /// @@ -43,7 +43,7 @@ public interface IEscrowCancel : ITransactionCommon public class EscrowCancelResponse : TransactionResponse, IEscrowCancel { /// - public uint OfferSequence { get; set; } + public uint? OfferSequence { get; set; } /// public string Owner { get; set; } } diff --git a/Xrpl/Models/Transactions/EscrowFinish.cs b/Xrpl/Models/Transactions/EscrowFinish.cs index 98118d77..b306d54f 100644 --- a/Xrpl/Models/Transactions/EscrowFinish.cs +++ b/Xrpl/Models/Transactions/EscrowFinish.cs @@ -31,7 +31,7 @@ public EscrowFinish(string owner, uint offerSequence, string condition, string f public string Owner { get; set; } /// - public uint OfferSequence { get; set; } + public uint? OfferSequence { get; set; } /// public string Condition { get; set; } @@ -64,7 +64,7 @@ public interface IEscrowFinish : ITransactionCommon /// Transaction sequence of EscrowCreate transaction that created the held.
/// payment to finish. ///
- uint OfferSequence { get; set; } + uint? OfferSequence { get; set; } /// /// Address of the source account that funded the escrow. /// @@ -86,7 +86,7 @@ public class EscrowFinishResponse : TransactionResponse, IEscrowFinish /// public string Fulfillment { get; set; } /// - public uint OfferSequence { get; set; } + public uint? OfferSequence { get; set; } /// public string Owner { get; set; } diff --git a/Xrpl/Models/Transactions/LedgerStateFix.cs b/Xrpl/Models/Transactions/LedgerStateFix.cs index b3667f2c..26a6a8c5 100644 --- a/Xrpl/Models/Transactions/LedgerStateFix.cs +++ b/Xrpl/Models/Transactions/LedgerStateFix.cs @@ -15,7 +15,7 @@ public interface ILedgerStateFix : ITransactionCommon /// /// The type of ledger fix to apply. /// - ushort LedgerFixType { get; set; } + ushort? LedgerFixType { get; set; } /// /// The owner account whose ledger objects need fixing. @@ -33,7 +33,7 @@ public LedgerStateFix() /// [JsonPropertyName("LedgerFixType")] - public ushort LedgerFixType { get; set; } + public ushort? LedgerFixType { get; set; } /// [JsonPropertyName("Owner")] @@ -49,7 +49,7 @@ public class LedgerStateFixResponse : TransactionResponse, ILedgerStateFix { /// [JsonPropertyName("LedgerFixType")] - public ushort LedgerFixType { get; set; } + public ushort? LedgerFixType { get; set; } /// [JsonPropertyName("Owner")] diff --git a/Xrpl/Models/Transactions/MPTokenIssuanceCreate.cs b/Xrpl/Models/Transactions/MPTokenIssuanceCreate.cs index b4bd61b4..702acfe0 100644 --- a/Xrpl/Models/Transactions/MPTokenIssuanceCreate.cs +++ b/Xrpl/Models/Transactions/MPTokenIssuanceCreate.cs @@ -47,7 +47,19 @@ public enum MPTokenIssuanceCreateFlags : uint /// /// If set, indicates that the issuer can clawback tokens. /// - tfMPTCanClawback = 64 + tfMPTCanClawback = 64, + + /// + /// If set, holders of this issuance may hold a confidential balance. + /// + /// + /// rippled maps this straight onto the ledger flag + /// (TF_FLAG(tfMPTCanHoldConfidentialBalance, lsfMPTCanHoldConfidentialBalance), + /// TxFlags.h), and lsfMPTCanHoldConfidentialBalance is 0x00000080 in LedgerFormats.h. + /// The Set-side counterpart (MPTokenIssuanceSetFlags.tfMPTSetCanHoldConfidentialBalance) + /// was already here; only the create-side flag was missing. + /// + tfMPTCanHoldConfidentialBalance = 128 } /// diff --git a/Xrpl/Models/Transactions/MemoRules.cs b/Xrpl/Models/Transactions/MemoRules.cs new file mode 100644 index 00000000..6136e919 --- /dev/null +++ b/Xrpl/Models/Transactions/MemoRules.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; + +using Xrpl.BinaryCodec.Binary; +using Xrpl.BinaryCodec.Types; +using Xrpl.Client.Exceptions; +using Xrpl.Client.Json; + +namespace Xrpl.Models.Transactions +{ + /// + /// The limits a node puts on Memos before it will accept a transaction at all. + /// + /// + /// + /// These are local checks in rippled's passesLocalChecksisMemoOkay + /// (src/libxrpl/protocol/STTx.cpp). A transaction that fails them is not relayed, never + /// reaches a ledger and costs no fee - but the consumer only finds out after building, + /// autofilling and signing it, and the refusal does not say which field was at fault. Checking + /// the same rules before signing turns that into an exception that names the problem. + /// + /// + /// Two of the five rules the node applies are already enforced by the binary codec, so they are + /// deliberately not repeated here: a member other than MemoType, MemoData or + /// MemoFormat inside a Memo is refused when the blob is built, and so is a value + /// that is not hex. Re-checking them would mean two places to keep in step with one truth. + /// + /// + public static class MemoRules + { + /// + /// The largest the serialized Memos array may be, in bytes. + /// + /// + /// The limit is on the array as a whole, so several memos do not raise it. In practice this + /// leaves 1019 bytes of MemoData in a single memo carrying nothing else. + /// + public const int MaxSerializedLength = 1024; + + /// + /// Characters a decoded MemoType or MemoFormat may consist of - the ones RFC + /// 3986 allows in a URL. MemoData is exempt: it carries arbitrary bytes. + /// + private const string UrlSafeCharacters = + "0123456789" + + "-._~:/?#[]@!$&'()*+,;=%" + + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "abcdefghijklmnopqrstuvwxyz"; + + private static readonly bool[] AllowedInMemoType = BuildAllowedTable(); + + /// + /// Checks a transaction's Memos against the rules a node applies locally. + /// + /// The value of the transaction's Memos field, or null. + /// + /// When the array is too large, an element is not a Memo object, or a + /// MemoType/MemoFormat decodes to something a URL may not contain. + /// + public static void Validate(object memos) + { + if (memos is null) + { + return; + } + + JsonNode node = memos as JsonNode ?? JsonSerializer.SerializeToNode(memos, XrplJsonOptions.Default); + + // Anything that is not an array of well-formed Memo objects is refused by the codec a + // moment later, and refused better: it names the member at fault. These rules only add + // what nothing else checks, so a shape they cannot read is left alone rather than + // reported here in poorer words. + if (node is not JsonArray array || array.Count == 0) + { + return; + } + + bool everyElementIsAMemo = true; + foreach (JsonNode element in array) + { + JsonObject memo = TryUnwrapMemo(element); + if (memo is null) + { + everyElementIsAMemo = false; + continue; + } + + ValidateUrlSafe(memo, "MemoType"); + ValidateUrlSafe(memo, "MemoFormat"); + } + + if (!everyElementIsAMemo) + { + return; + } + + int length = SerializedLength(array); + if (length < 0) + { + return; + } + + if (length > MaxSerializedLength) + { + throw new ValidationException( + $"Memos: the serialized array is {length} bytes and a node accepts at most " + + $"{MaxSerializedLength}. The limit is on the whole array, so splitting the " + + $"content across several memos does not help."); + } + } + + /// + /// Measures the Memos array the way the node measures it. + /// + /// + /// rippled serializes the array with STArray::add, which writes each element as its + /// object start marker, the fields, and the object end marker - the array's own markers are + /// not part of the length it then compares. writes exactly + /// that, which is why the measurement is taken here rather than reimplemented. + /// + private static int SerializedLength(JsonArray array) + { + try + { + StArray serialized = StArray.FromJson(array); + BytesList sink = new BytesList(); + serialized.ToBytes(sink); + return sink.BytesLength(); + } + catch (Exception) + { + // Content the codec cannot serialize at all - a value that is not hex, say. It + // will refuse the transaction when it builds the blob and explain why; measuring + // is not the place to find that out, and reporting it from here would replace a + // precise message with a vague one. + return -1; + } + } + + /// + /// Returns the memo itself from an array element, or null when the element is not + /// shaped like one. + /// + /// + /// On the wire a Memos element is an object with the single member Memo - + /// rippled refuses an array holding anything else ("A memo array may contain only Memo + /// objects"), and so does the codec, naming the member that does not belong. Anything else + /// is therefore reported as null rather than thrown on. + /// + private static JsonObject TryUnwrapMemo(JsonNode element) + { + if (element is JsonObject wrapper && + wrapper.Count == 1 && + wrapper.TryGetPropertyValue("Memo", out JsonNode inner) && + inner is JsonObject memo) + { + return memo; + } + + return null; + } + + private static void ValidateUrlSafe(JsonObject memo, string field) + { + if (!memo.TryGetPropertyValue(field, out JsonNode value) || value is null) + { + return; + } + + // Not a string at all - a number, say. Reading it as one would throw + // InvalidOperationException from inside the JSON reader: neither an exception a caller + // of this SDK expects nor a message that names the field. The codec refuses it and says + // which member is wrong, so it is left to do that. + if (value is not JsonValue jsonValue || !jsonValue.TryGetValue(out string hex)) + { + return; + } + + if (string.IsNullOrEmpty(hex)) + { + return; + } + + byte[] decoded; + try + { + decoded = Convert.FromHexString(hex); + } + catch (FormatException) + { + // Left to the codec, which refuses non-hex when it builds the blob and says so in + // its own words. Reporting it twice, differently, would only add a second story. + return; + } + + foreach (byte character in decoded) + { + if (!AllowedInMemoType[character]) + { + throw new ValidationException( + $"Memos: {field} decodes to a character a URL may not contain " + + $"(byte 0x{character:X2}). A node allows only alphanumerics and " + + $"-._~:/?#[]@!$&'()*+,;=% there; arbitrary bytes belong in MemoData."); + } + } + } + + private static bool[] BuildAllowedTable() + { + bool[] allowed = new bool[256]; + foreach (char character in UrlSafeCharacters) + { + allowed[character] = true; + } + + return allowed; + } + } +} diff --git a/Xrpl/Models/Transactions/NFTokenAcceptOffer.cs b/Xrpl/Models/Transactions/NFTokenAcceptOffer.cs index 38c07f6a..85fedb69 100644 --- a/Xrpl/Models/Transactions/NFTokenAcceptOffer.cs +++ b/Xrpl/Models/Transactions/NFTokenAcceptOffer.cs @@ -2,6 +2,7 @@ // https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/src/models/transactions/NFTokenAcceptOffer.ts +using System; using System.Collections.Generic; using System.Text.Json.Serialization; using System.Threading.Tasks; @@ -20,9 +21,6 @@ public NFTokenAcceptOffer() TransactionType = TransactionType.NFTokenAcceptOffer; } - /// - public string NFTokenID { get; set; } - /// public string NFTokenSellOffer { get; set; } @@ -49,7 +47,6 @@ public NFTokenAcceptOffer() /// public interface INFTokenAcceptOffer : ITransactionCommon { - string NFTokenID { get; set; } //todo unknown field /// /// Identifies the NFTokenOffer that offers to sell the NFToken.
/// In direct mode this field is optional, but either NFTokenSellOffer or NFTokenBuyOffer must be specified.
@@ -69,6 +66,14 @@ public interface INFTokenAcceptOffer : ITransactionCommon /// This functionality is intended to allow the owner of an NFToken to offer their token for sale to a third party broker, who may then attempt to sell the NFToken on for a larger amount, without the broker having to own the NFToken or custody funds.
/// Note: in brokered mode, the offers referenced by NFTokenBuyOffer and NFTokenSellOffer must both specify the same NFTokenID; that is, both must be for the same NFToken. ///
+ /// + /// The transaction itself carries no NFTokenID field - the token is whichever one both + /// offers are for. Brokered mode also needs three distinct accounts: rippled checks the + /// owner of each offer against the submitter separately, not as alternatives, so a broker + /// who is also the buyer or the seller gets tecCANT_ACCEPT_OWN_NFTOKEN_OFFER. The + /// two blocks in its preclaim read like a choice between direct and brokered mode + /// and are not one - both run in brokered mode. + /// [JsonConverter(typeof(CurrencyConverter))] public Currency NFTokenBrokerFee { get; set; } } @@ -76,9 +81,6 @@ public interface INFTokenAcceptOffer : ITransactionCommon /// public class NFTokenAcceptOfferResponse : TransactionResponse, INFTokenAcceptOffer { - /// - public string NFTokenID { get; set; } - /// public string NFTokenSellOffer { get; set; } @@ -127,6 +129,19 @@ public static async Task ValidateNFTokenAcceptOffer(Dictionary t if ((!can_get_value_NFTokenSellOffer && !can_get_value_NFTokenBuyOffer) || (NFTokenSellOffer is null && NFTokenBuyOffer is null)) throw new ValidationException("NFTokenAcceptOffer: must set either NFTokenSellOffer or NFTokenBuyOffer"); + + // Brokered mode takes two different offers. Naming the same one twice is a guaranteed + // refusal - the offer's owner is compared with the submitter on both sides, so one of + // the two comparisons is the account against itself and the node answers + // tecCANT_ACCEPT_OWN_NFTOKEN_OFFER. That is a tec: it reaches a ledger and costs the + // fee. One comparison here, with nothing to ask the node, saves it. + if (NFTokenSellOffer is string sellOffer && + NFTokenBuyOffer is string buyOffer && + string.Equals(sellOffer, buyOffer, StringComparison.OrdinalIgnoreCase)) + { + throw new ValidationException( + "NFTokenAcceptOffer: NFTokenSellOffer and NFTokenBuyOffer must be two different offers"); + } } } diff --git a/Xrpl/Models/Transactions/NFTokenCreateOffer.cs b/Xrpl/Models/Transactions/NFTokenCreateOffer.cs index 31bebef6..e80d744b 100644 --- a/Xrpl/Models/Transactions/NFTokenCreateOffer.cs +++ b/Xrpl/Models/Transactions/NFTokenCreateOffer.cs @@ -171,7 +171,7 @@ public static Task ValidateNFTokenCreateOffer(Dictionary tx) if (tx.TryGetValue("Flags", out var Flags) && Flags is uint {} flags - && Utils.Index.IsFlagEnabled(flags,(uint)NFTokenCreateOfferFlags.tfSellNFToken)) + && Utils.ModelUtils.IsFlagEnabled(flags,(uint)NFTokenCreateOfferFlags.tfSellNFToken)) { ValidateNFTokenSellOfferCases(tx); } diff --git a/Xrpl/Models/Transactions/NFTokenMint.cs b/Xrpl/Models/Transactions/NFTokenMint.cs index d62cfbd0..8c3fdde8 100644 --- a/Xrpl/Models/Transactions/NFTokenMint.cs +++ b/Xrpl/Models/Transactions/NFTokenMint.cs @@ -45,6 +45,7 @@ public enum NFTokenMintFlags : uint /// /// If set, indicates that this NFT's URI can be modified. + /// tfMutable = 16, } /// @@ -63,7 +64,7 @@ public NFTokenMint() } /// - public uint NFTokenTaxon { get; set; } + public uint? NFTokenTaxon { get; set; } /// public string Issuer { get; set; } @@ -99,7 +100,7 @@ public interface INFTokenMint : ITransactionCommon /// The implementation reserves taxon identifiers greater than or equal to 2147483648 (0x80000000).
/// If you have no use for this field, set it to 0. ///
- uint NFTokenTaxon { get; set; } + uint? NFTokenTaxon { get; set; } /// /// Indicates the account that should be the issuer of this token.
/// This value is optional and should only be specified if the account executing the transaction is not the `Issuer` of the `NFToken` object.
@@ -145,7 +146,7 @@ public class NFTokenMintResponse : TransactionResponse, INFTokenMint } /// - public uint NFTokenTaxon { get; set; } + public uint? NFTokenTaxon { get; set; } /// public string Issuer { get; set; } diff --git a/Xrpl/Models/Transactions/OfferCancel.cs b/Xrpl/Models/Transactions/OfferCancel.cs index 8ad431d1..ed3de503 100644 --- a/Xrpl/Models/Transactions/OfferCancel.cs +++ b/Xrpl/Models/Transactions/OfferCancel.cs @@ -18,7 +18,7 @@ public OfferCancel() } /// - public uint OfferSequence { get; set; } + public uint? OfferSequence { get; set; } } /// @@ -32,14 +32,14 @@ public interface IOfferCancel : ITransactionCommon /// It is not considered an error if the offer.
/// specified does not exist. ///
- uint OfferSequence { get; set; } + uint? OfferSequence { get; set; } } /// public class OfferCancelResponse : TransactionResponse, IOfferCancel { /// - public uint OfferSequence { get; set; } + public uint? OfferSequence { get; set; } } public partial class Validation diff --git a/Xrpl/Models/Transactions/OracleDelete.cs b/Xrpl/Models/Transactions/OracleDelete.cs index 346816a7..04f9dff5 100644 --- a/Xrpl/Models/Transactions/OracleDelete.cs +++ b/Xrpl/Models/Transactions/OracleDelete.cs @@ -17,7 +17,7 @@ public interface IOracleDelete : ITransactionCommon /// /// A unique identifier of the price oracle for the Account. /// - uint OracleDocumentID { get; set; } + uint? OracleDocumentID { get; set; } } /// @@ -33,7 +33,7 @@ public OracleDelete() /// [JsonPropertyName("OracleDocumentID")] - public uint OracleDocumentID { get; set; } + public uint? OracleDocumentID { get; set; } } /// @@ -41,7 +41,7 @@ public class OracleDeleteResponse : TransactionResponse, IOracleDelete { /// [JsonPropertyName("OracleDocumentID")] - public uint OracleDocumentID { get; set; } + public uint? OracleDocumentID { get; set; } } public partial class Validation diff --git a/Xrpl/Models/Transactions/OracleSet.cs b/Xrpl/Models/Transactions/OracleSet.cs index d3f6bdcb..1948286c 100644 --- a/Xrpl/Models/Transactions/OracleSet.cs +++ b/Xrpl/Models/Transactions/OracleSet.cs @@ -20,7 +20,7 @@ public interface IOracleSet : ITransactionCommon /// /// A unique identifier of the price oracle for the Account. /// - uint OracleDocumentID { get; set; } + uint? OracleDocumentID { get; set; } /// /// The time the data was last updated, represented as a unix timestamp in seconds. @@ -69,7 +69,7 @@ public OracleSet() /// [JsonPropertyName("OracleDocumentID")] - public uint OracleDocumentID { get; set; } + public uint? OracleDocumentID { get; set; } /// [JsonPropertyName("LastUpdateTime")] @@ -101,7 +101,7 @@ public class OracleSetResponse : TransactionResponse, IOracleSet { /// [JsonPropertyName("OracleDocumentID")] - public uint OracleDocumentID { get; set; } + public uint? OracleDocumentID { get; set; } /// [JsonPropertyName("LastUpdateTime")] diff --git a/Xrpl/Models/Transactions/Payment.cs b/Xrpl/Models/Transactions/Payment.cs index 6338a821..b28aa3aa 100644 --- a/Xrpl/Models/Transactions/Payment.cs +++ b/Xrpl/Models/Transactions/Payment.cs @@ -8,10 +8,8 @@ using Xrpl.Client.Json.Converters; using Xrpl.Models.Common; using Xrpl.Models.Enums; -using Xrpl.Models.Methods; using Xrpl.Models.Utils; -using Index = Xrpl.Models.Utils.Index; // https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/src/models/transactions/payment.ts @@ -45,6 +43,18 @@ public enum PaymentFlags : uint /// See Limit Quality for details. /// tfLimitQuality = 262144, + + /// + /// The sponsor covers the reserve of an account this payment creates (XLS-68). + /// + /// + /// rippled declares it in the Payment block of TxFlags.h as 0x00080000. The rest of + /// XLS-68 was already modelled here - the Sponsor fields, SponsorshipSet, SponsorshipFlags + /// - and only this flag was missing, which is what a conformance check over TxFlags.h + /// would have caught. No such check exists: ledger flags are verified against + /// LedgerFormats.h, transaction flags against nothing at all. + /// + tfSponsorCreatedAccount = 524288, } /// @@ -64,7 +74,29 @@ public Payment() /// API v2 renames the ledger's Amount field to DeliverMax on the wire and omits Amount entirely. /// System.Text.Json skips non-public members unless they carry [JsonInclude], so this attribute /// is what keeps the alias wired up — without it Amount silently stays null on every v2 payload. - /// The property is set-only, so DeliverMax is never written back out. + /// + /// The property is deliberately set-only, so DeliverMax is never written back out, unlike its + /// counterpart on . Do not "fix" this asymmetry by copying + /// PaymentResponse's read/write alias pair here — it would be a signing-safety regression, not + /// a cleanup. The reason: DeliverMax has no entry of its own in + /// Base/Xrpl.BinaryCodec/Enums/definitions.json — checked directly: Amount, + /// DeliverMin and SendMax are all present there, DeliverMax is not, because it + /// is a JSON API v2 presentation-layer rename with no binary field code of its own. A + /// is read-only display data, so preserving the wire name it arrived + /// under is safe and correct for a reconciliation UI. This class instead feeds + /// ToJson()EncodeForSigning (see XrplWallet.Sign, which calls + /// ITransactionRequest.ToJson() before handing the dictionary to the binary codec): the + /// codec looks fields up by name in definitions.json, so an object that re-emitted + /// "DeliverMax" hands the codec a name it cannot resolve. What happens then depends on the + /// entry point, and both outcomes are bad — verified by execution: + /// EncodeForSigning raises InvalidJsonException: unknown field DeliverMax, so + /// signing fails outright, while a direct XrplBinaryCodec.Encode drops the member + /// silently and yields a blob with no amount in it at all. On the Sign path the loud + /// failure comes first, since the signature is computed before the final blob is built — but + /// "it throws instead" is not a reason to relax the rule, and nothing stops a caller from + /// reaching Encode directly. So Amount is always written here, regardless of which + /// name it came in under. + /// /// [JsonInclude] [JsonPropertyName("DeliverMax")] @@ -90,7 +122,7 @@ private Currency? DeliverMax public string InvoiceID { get; set; } /// - public List> Paths { get; set; } + public List> Paths { get; set; } /// [JsonConverter(typeof(CurrencyConverter))] @@ -170,7 +202,7 @@ public interface IPayment : ITransactionCommon, IDestination /// Array of payment paths to be used for this transaction.
/// Must be omitted for XRP-to-XRP transactions. ///
- List> Paths { get; set; } + List> Paths { get; set; } /// /// Highest amount of source currency this transaction is allowed to cost, including transfer fees, exchange rates, and slippage.
/// Does not include the XRP destroyed as a cost for submitting the transaction.
@@ -196,24 +228,94 @@ public interface IPayment : ITransactionCommon, IDestination /// public class PaymentResponse : TransactionResponse, IPayment, IDestination { + /// + /// True once a value has been assigned through the setter below — + /// i.e. the node sent (or an earlier deserialize pass already saw) the field under its API v1 + /// name "Amount". Independent of : rippled's tx + /// method with api_version: 1 sends BOTH "Amount" and "DeliverMax" for the same + /// transaction (confirmed live against mainnet — see + /// Fixtures/Responses/tx_v1_raw.json), and calls both + /// property setters in the order the members appear in the source JSON. A single bool here + /// could only remember whichever setter ran last, so the earlier one's presence would be + /// lost — exactly the defect this pair of flags exists to avoid. + /// + private bool _receivedAsAmount; + + /// + /// True once a value has been assigned through the setter below — + /// i.e. the node sent the field under its API v2 name "DeliverMax". See + /// for why this is a second, independent flag rather than a + /// single "which one" bool. + /// + private bool _receivedAsDeliverMax; + /// - [JsonConverter(typeof(CurrencyConverter))] + /// + /// + /// The single value callers read, regardless of whether the node sent it as "Amount" (API v1), + /// "DeliverMax" (API v2), or both (API v1, confirmed live - see + /// ). Excluded from JSON directly: and + /// below own the wire representation, so every field name a value came + /// in under is one it goes back out under — callers never have to guess which one(s) fired. + /// + /// + /// One value behind two names, which is exact for every response rippled actually produces: + /// v2 sends DeliverMax alone, v1 sends both carrying the same amount. It is not exact if the + /// two ever disagree - the setters run in document order, the later one wins, and both names + /// then re-serialize with that single value. Measured on a hand-built payload where the node + /// "sent" Amount 1000000 and DeliverMax 999, the round trip emits 999 under both names, so + /// Amount reads as a value that was never sent. rippled cannot produce that response - the v1 + /// path duplicates one field rather than computing two - so this is a property of malformed + /// or hostile input, not of the protocol. Raw keeps the truth either way; a consumer + /// that must detect tampering should compare against it rather than trusting this projection. + /// + /// + [JsonIgnore] public Currency Amount { get; set; } + /// + /// JSON view of under its API v1 wire name "Amount". Serialized whenever + /// the value arrived under this name, or under neither name (an object assembled by + /// application code defaults to "Amount") - i.e. whenever it was NOT received exclusively as + /// DeliverMax. That "received both" case is what keeps this alias and + /// both emitting instead of one silently winning over the other. + /// + [JsonInclude] + [JsonPropertyName("Amount")] + [JsonConverter(typeof(CurrencyConverter))] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + private Currency AmountAlias + { + get => _receivedAsAmount || !_receivedAsDeliverMax ? Amount : null; + set + { + Amount = value; + _receivedAsAmount = true; + } + } + /// /// /// API v2 renames the ledger's Amount field to DeliverMax on the wire and omits Amount entirely. /// System.Text.Json skips non-public members unless they carry [JsonInclude], so this attribute /// is what keeps the alias wired up — without it Amount silently stays null on every v2 payload - /// (account_tx, tx with api_version 2, subscription streams). The property is set-only, so - /// DeliverMax is never written back out and cannot reach the binary codec. + /// (account_tx, tx with api_version 2, subscription streams). + /// Serialized only when the node actually sent "DeliverMax" - a value that arrived as DeliverMax + /// is written back out as DeliverMax, not silently renamed to Amount, and an object the node + /// never sent this field for does not gain it on round-trip. /// [JsonInclude] [JsonPropertyName("DeliverMax")] [JsonConverter(typeof(CurrencyConverter))] - private Currency? DeliverMax + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + private Currency DeliverMax { - set => Amount = value; + get => _receivedAsDeliverMax ? Amount : null; + set + { + Amount = value; + _receivedAsDeliverMax = true; + } } /// @@ -233,7 +335,7 @@ private Currency? DeliverMax public string InvoiceID { get; set; } /// - public List> Paths { get; set; } + public List> Paths { get; set; } /// [JsonConverter(typeof(CurrencyConverter))] @@ -313,7 +415,7 @@ public static Task CheckPartialPayment(Dictionary tx) } bool isTfPartialPayment = flags is uint uFlag - ? Index.IsFlagEnabled(uFlag, (uint)PaymentFlags.tfPartialPayment) + ? ModelUtils.IsFlagEnabled(uFlag, (uint)PaymentFlags.tfPartialPayment) : flags is PaymentFlags pf ? pf == PaymentFlags.tfPartialPayment : flags is Dictionary flagDict && CheckFlag(flagDict, "tfPartialPayment"); diff --git a/Xrpl/Models/Transactions/PaymentChannelCreate.cs b/Xrpl/Models/Transactions/PaymentChannelCreate.cs index f5080dad..dab5f534 100644 --- a/Xrpl/Models/Transactions/PaymentChannelCreate.cs +++ b/Xrpl/Models/Transactions/PaymentChannelCreate.cs @@ -26,7 +26,7 @@ public PaymentChannelCreate() public string Destination { get; set; } /// - public uint SettleDelay { get; set; } + public uint? SettleDelay { get; set; } /// public string PublicKey { get; set; } @@ -77,7 +77,7 @@ public interface IPaymentChannelCreate : ITransactionCommon, IDestination /// /// Amount of time the source address must wait before closing the channel if it has unclaimed XRP. /// - uint SettleDelay { get; set; } + uint? SettleDelay { get; set; } /// /// (Optional) Arbitrary integer used to identify the reason for this payment, or a sender on whose behalf this transaction is made.
/// Conventionally, a refund should specify the initial payment's SourceTag as the refund payment's DestinationTag. @@ -95,7 +95,7 @@ public class PaymentChannelCreateResponse : TransactionResponse, IPaymentChannel public string Destination { get; set; } /// - public uint SettleDelay { get; set; } + public uint? SettleDelay { get; set; } /// public string PublicKey { get; set; } diff --git a/Xrpl/Models/Transactions/SetFee.cs b/Xrpl/Models/Transactions/SetFee.cs index f24bb37e..5e2ed071 100644 --- a/Xrpl/Models/Transactions/SetFee.cs +++ b/Xrpl/Models/Transactions/SetFee.cs @@ -17,16 +17,16 @@ public SetFee() public string BaseFee { get; set; } /// - public uint ReferenceFeeUnits { get; set; } + public uint? ReferenceFeeUnits { get; set; } /// - public uint ReserveBase { get; set; } + public uint? ReserveBase { get; set; } /// - public uint ReserveIncrement { get; set; } + public uint? ReserveIncrement { get; set; } /// - public uint LedgerSequence { get; set; } + public uint? LedgerSequence { get; set; } /// XRPFees: base fee in drops. [JsonConverter(typeof(CurrencyConverter))] @@ -53,19 +53,19 @@ public interface ISetFee : ITransactionCommon /// The index of the ledger version where this pseudo-transaction appears.
/// This distinguishes the pseudo-transaction from other occurrences of the same change. ///
- uint LedgerSequence { get; set; } + uint? LedgerSequence { get; set; } /// /// The cost, in fee units, of the reference transaction /// - uint ReferenceFeeUnits { get; set; } + uint? ReferenceFeeUnits { get; set; } /// /// The base reserve, in drops /// - uint ReserveBase { get; set; } + uint? ReserveBase { get; set; } /// /// The incremental reserve, in drops /// - uint ReserveIncrement { get; set; } + uint? ReserveIncrement { get; set; } } public class SetFeeResponse : TransactionResponse, ISetFee @@ -73,13 +73,13 @@ public class SetFeeResponse : TransactionResponse, ISetFee /// public string BaseFee { get; set; } /// - public uint LedgerSequence { get; set; } + public uint? LedgerSequence { get; set; } /// - public uint ReferenceFeeUnits { get; set; } + public uint? ReferenceFeeUnits { get; set; } /// - public uint ReserveBase { get; set; } + public uint? ReserveBase { get; set; } /// - public uint ReserveIncrement { get; set; } + public uint? ReserveIncrement { get; set; } /// XRPFees: base fee in drops. [JsonConverter(typeof(CurrencyConverter))] diff --git a/Xrpl/Models/Transactions/SignerListSet.cs b/Xrpl/Models/Transactions/SignerListSet.cs index ccaba47a..0509094c 100644 --- a/Xrpl/Models/Transactions/SignerListSet.cs +++ b/Xrpl/Models/Transactions/SignerListSet.cs @@ -19,7 +19,7 @@ public SignerListSet() } /// - public uint SignerQuorum { get; set; } + public uint? SignerQuorum { get; set; } /// public List SignerEntries { get; set; } } @@ -40,7 +40,7 @@ public interface ISignerListSet : ITransactionCommon /// A multi-signature from this list is valid only if the sum weights of the signatures provided is greater than or equal to this value.
/// To delete a signer list, use the value 0. ///
- uint SignerQuorum { get; set; } + uint? SignerQuorum { get; set; } } /// @@ -49,7 +49,7 @@ public class SignerListSetResponse : TransactionResponse, ISignerListSet /// public List SignerEntries { get; set; } /// - public uint SignerQuorum { get; set; } + public uint? SignerQuorum { get; set; } } public partial class Validation diff --git a/Xrpl/Models/Transactions/TicketCreate.cs b/Xrpl/Models/Transactions/TicketCreate.cs index 2703e046..052ee7b6 100644 --- a/Xrpl/Models/Transactions/TicketCreate.cs +++ b/Xrpl/Models/Transactions/TicketCreate.cs @@ -18,7 +18,7 @@ public TicketCreate() /// - public uint TicketCount { get; set; } + public uint? TicketCount { get; set; } } /// @@ -30,14 +30,14 @@ public interface ITicketCreate : ITransactionCommon /// How many Tickets to create.
/// This must be a positive number and cannot cause the account to own more than 250 Tickets after executing this transaction. ///
- public uint TicketCount { get; set; } + public uint? TicketCount { get; set; } } /// public class TicketCreateResponse : TransactionResponse, ITicketCreate { /// - public uint TicketCount { get; set; } + public uint? TicketCount { get; set; } } public partial class Validation diff --git a/Xrpl/Models/Transactions/UNLModify.cs b/Xrpl/Models/Transactions/UNLModify.cs index 7a54e600..1f2f1abd 100644 --- a/Xrpl/Models/Transactions/UNLModify.cs +++ b/Xrpl/Models/Transactions/UNLModify.cs @@ -13,10 +13,10 @@ public UNLModify() public string UNLModifyValidator { get; set; } /// - public uint UNLModifyDisabling { get; set; } + public uint? UNLModifyDisabling { get; set; } /// - public uint LedgerSequence { get; set; } + public uint? LedgerSequence { get; set; } } public interface IUNLModify : ITransactionCommon @@ -31,13 +31,13 @@ public interface IUNLModify : ITransactionCommon /// If 0, this change represents removing a validator from the Negative UNL.
/// (No other values are allowed.) ///
- uint UNLModifyDisabling { get; set; } + uint? UNLModifyDisabling { get; set; } /// /// The ledger index where this pseudo-transaction appears.
/// This distinguishes the pseudo-transaction from other occurrences of the same change. ///
- uint LedgerSequence { get; set; } + uint? LedgerSequence { get; set; } } public class UNLModifyResponse : TransactionResponse, IUNLModify @@ -46,10 +46,10 @@ public class UNLModifyResponse : TransactionResponse, IUNLModify public string UNLModifyValidator { get; set; } /// - public uint UNLModifyDisabling { get; set; } + public uint? UNLModifyDisabling { get; set; } /// - public uint LedgerSequence { get; set; } + public uint? LedgerSequence { get; set; } } } diff --git a/Xrpl/Models/Transactions/XChainAddAccountCreateAttestation.cs b/Xrpl/Models/Transactions/XChainAddAccountCreateAttestation.cs index a47d15fb..f1a614b7 100644 --- a/Xrpl/Models/Transactions/XChainAddAccountCreateAttestation.cs +++ b/Xrpl/Models/Transactions/XChainAddAccountCreateAttestation.cs @@ -72,7 +72,7 @@ public interface IXChainAddAccountCreateAttestation : ITransactionCommon /// A boolean representing the chain where the event occurred. /// 0 represents the issuing chain, 1 represents the locking chain. ///
- byte WasLockingChainSend { get; set; } + byte? WasLockingChainSend { get; set; } } /// @@ -127,7 +127,7 @@ public XChainAddAccountCreateAttestation() /// [JsonPropertyName("WasLockingChainSend")] - public byte WasLockingChainSend { get; set; } + public byte? WasLockingChainSend { get; set; } } /// @@ -177,7 +177,7 @@ public class XChainAddAccountCreateAttestationResponse : TransactionResponse, IX /// [JsonPropertyName("WasLockingChainSend")] - public byte WasLockingChainSend { get; set; } + public byte? WasLockingChainSend { get; set; } } public partial class Validation diff --git a/Xrpl/Models/Transactions/XChainAddClaimAttestation.cs b/Xrpl/Models/Transactions/XChainAddClaimAttestation.cs index 35196f6f..65984486 100644 --- a/Xrpl/Models/Transactions/XChainAddClaimAttestation.cs +++ b/Xrpl/Models/Transactions/XChainAddClaimAttestation.cs @@ -66,7 +66,7 @@ public interface IXChainAddClaimAttestation : ITransactionCommon /// A boolean representing the chain where the event occurred. /// 0 represents the issuing chain, 1 represents the locking chain. ///
- byte WasLockingChainSend { get; set; } + byte? WasLockingChainSend { get; set; } } /// @@ -116,7 +116,7 @@ public XChainAddClaimAttestation() /// [JsonPropertyName("WasLockingChainSend")] - public byte WasLockingChainSend { get; set; } + public byte? WasLockingChainSend { get; set; } } /// @@ -161,7 +161,7 @@ public class XChainAddClaimAttestationResponse : TransactionResponse, IXChainAdd /// [JsonPropertyName("WasLockingChainSend")] - public byte WasLockingChainSend { get; set; } + public byte? WasLockingChainSend { get; set; } } public partial class Validation diff --git a/Xrpl/Models/Utils/BatchNormalizer.cs b/Xrpl/Models/Utils/BatchNormalizer.cs index d83e4ea4..4c771246 100644 --- a/Xrpl/Models/Utils/BatchNormalizer.cs +++ b/Xrpl/Models/Utils/BatchNormalizer.cs @@ -99,10 +99,22 @@ async Task GetNextSeqAsync(string account) var ai = await client.AccountInfo(new AccountInfoRequest(account) { LedgerIndex = new LedgerIndex(LedgerIndexType.Current) - }, cancellationToken); - var start = ai.AccountData.Sequence; - nextSeqByAccount[account] = start; - return start; + }, cancellationToken).Typed(); + // account_info for the current ledger always returns a full AccountRoot with a Sequence; + // a missing AccountData (or a missing Sequence within it) means a malformed node response + // and must fail loudly rather than dereference a null AccountData or silently treat it as 0. + if (ai.AccountData is null) + { + throw new ValidationException($"account_info response for '{account}' did not include account_data."); + } + + uint? start = ai.AccountData.Sequence; + if (start == null) + { + throw new ValidationException($"account_info response for '{account}' did not include the account's Sequence."); + } + nextSeqByAccount[account] = start.Value; + return start.Value; } void Bump(string account) @@ -168,7 +180,12 @@ public static List> ToRawList(object rawTransactions) ///
public static string ComputeInnerTxId(this JsonObject normalizedInnerTx) { - var st = Xrpl.BinaryCodec.Types.StObject.FromJson(JsonNode.Parse(normalizedInnerTx.ToJsonString())); + // Strict: this id is what the outer Batch signature commits to. Parsed leniently, a + // member the codec does not know would be dropped from the bytes being hashed, so the + // signature would fix an inner transaction other than the one the caller was shown - and + // nothing would say so. Strict without the signing filter, because an id covers the whole + // transaction, signing fields and not. + var st = Xrpl.BinaryCodec.Types.StObject.FromJsonStrict(JsonNode.Parse(normalizedInnerTx.ToJsonString())); var bytes = st.ToBytes(); var prefix = Xrpl.BinaryCodec.Util.Bits.GetBytes((uint)Xrpl.BinaryCodec.Hashing.HashPrefix.TransactionId); diff --git a/Xrpl/Models/Utils/ModelUtils.cs b/Xrpl/Models/Utils/ModelUtils.cs index 316d565b..2a783046 100644 --- a/Xrpl/Models/Utils/ModelUtils.cs +++ b/Xrpl/Models/Utils/ModelUtils.cs @@ -6,9 +6,18 @@ using System.Collections.Generic; using System.Linq; -namespace Xrpl.Models.Utils //todo ? +namespace Xrpl.Models.Utils { - public static class Index + /// + /// Helpers shared by the models. + /// + /// + /// Called Index until 11.0.0.0 - a calque of the barrel file utils/index.ts it was + /// ported from, and a name that collides with , which is in scope in + /// every file whether anyone asked for it or not. The class now matches the file it has always + /// lived in. + /// + public static class ModelUtils { /// /// Verify that all fields of an object are in fields. @@ -18,7 +27,7 @@ public static class Index /// True if keys in object are all in fields. public static bool OnlyHasFields(this Dictionary obj, string[] fields) => obj.Keys.All(key => fields.Contains(key)); /// - /// Perform bitwise AND (&) to check if a flag is enabled within Flags (as a number). + /// Perform bitwise AND (&) to check if a flag is enabled within Flags (as a number). /// /// A number that represents flags enabled. /// A specific flag to check if it's enabled within Flags. diff --git a/Xrpl/Sugar/AmmMath.cs b/Xrpl/Sugar/AmmMath.cs new file mode 100644 index 00000000..203274ca --- /dev/null +++ b/Xrpl/Sugar/AmmMath.cs @@ -0,0 +1,584 @@ +using System; + +namespace Xrpl.Sugar +{ + /// + /// What an AMM pool will do before you ask it to - what a deposit is worth in LP tokens, what a + /// withdrawal costs, what a swap pays out, and each of those read backwards - computed the way + /// rippled computes it. + /// + /// + /// + /// The formulas are equations 3 and 7 from rippled's AMMHelpers.cpp, not the ones that + /// circulate as the "AMM single-sided deposit formula". The circulating one, + /// T·(√(1 + b·(1 − f/2)/B) − 1), is close and always wrong in the same direction: a + /// deposit the size of the pool at a 1% fee gives 0.41244·T against rippled's + /// 0.41213·T, an error of 0.08%. reproduces + /// the node's figure. + /// + /// + /// Two things decide whether an estimate matches what the node actually credits, and neither is + /// in the formulas: + /// + /// + /// Whose fee. The holder of the pool's auction slot trades at + /// DiscountedFee - a tenth of the pool's trading fee - and the node computes their + /// deposits and withdrawals at that rate too. Estimating at the pool's fee is wrong for them; + /// see . + /// How fresh the pool state is. amm_info has to be read + /// immediately before the calculation. Run against a snapshot taken when a screen opened, the + /// drift looks exactly like an error in the arithmetic. + /// + /// + /// What comes back is a bound rather than the exact credit, and the direction is known. Under + /// fixAMMv1_3 rippled rounds the final multiplication against the caller in both + /// directions - lpTokensOut downward ("minimize tokens out"), lpTokensIn upward + /// ("maximize tokens in") - so a deposit is credited this much or a shade less, and a + /// withdrawal costs this much or a shade more. The difference lands in the last of + /// STAmount's 15 significant digits, which is below what differencing two reported LP + /// token balances can resolve. + /// + /// + /// Units are the caller's and nothing here converts between them. That matters most for XRP: + /// amm_info reports the XRP side of a pool in drops, so a balance read from it and an + /// amount the caller is thinking of in XRP are a million apart. Mixing the two in one call + /// returns a number that reads as a broken formula rather than as a unit mistake. + /// + /// + /// Everything is computed in rather than : 28 + /// significant digits against 15. That is also why the square root here is Newton's method - + /// would throw away the precision the rest of the calculation keeps. + /// + /// + public static class AmmMath + { + /// + /// What a TradingFee of 1 is worth as a fraction: 1/100 000, so 1000 is one per cent. + /// + /// + /// rippled's kAuctionSlotFeeScaleFactor. The field is in units of 1/10 of a basis + /// point, which is easy to be out by a factor of ten on. + /// + public const uint TradingFeeScale = 100_000; + + /// + /// How much cheaper the auction slot holder's fee is than the pool's. + /// + /// rippled's kAuctionSlotDiscountedFeeFraction. + public const uint AuctionSlotFeeDiscount = 10; + + /// + /// The largest TradingFee a pool can have: 1000, one per cent. + /// + /// + /// rippled's kTradingFeeThreshold, and the reason a fee is checked against it here. + /// The field is in units of 1/10 of a basis point, so a caller who reaches for basis points + /// or for whole per cent is out by a factor of ten or a hundred - and the arithmetic below + /// would carry on and return a plausible number rather than say so. + /// + public const uint TradingFeeThreshold = 1000; + + /// + /// The trading fee as a fraction of 1. + /// + /// The pool's TradingFee. rippled caps it at 1000 - one per cent - in kTradingFeeThreshold. + /// exceeds . + public static decimal TradingFeeFraction(uint tradingFee) + { + RequireValidTradingFee(tradingFee); + return (decimal)tradingFee / TradingFeeScale; + } + + /// + /// The fee the auction slot holder trades at. + /// + /// + /// Use this in place of the pool's fee when the account holding the slot is the one + /// depositing or withdrawing - the node does, and an estimate at the pool's fee will not + /// match what it credits. + /// + /// The pool's TradingFee. + /// exceeds . + public static uint DiscountedTradingFee(uint tradingFee) + { + RequireValidTradingFee(tradingFee); + return tradingFee / AuctionSlotFeeDiscount; + } + + /// + /// LP tokens credited for depositing one asset only. + /// + /// + /// + /// Equation 3: with f1 = 1 − fee, f2 = (1 − fee/2)/f1 and r = b/B, + /// + /// + /// c = √(f2² + r/f1) − f2 + /// t = T · (r − c) / (1 + c) + /// + /// + /// The node rounds the last multiplication down, so it credits this or a shade less. + /// + /// + /// The plus under the root is deliberate and is what rippled computes. The comment above + /// that equation in AMMHelpers.cpp writes it as √(f2² − b/(B·f1)), but the + /// code uses +, and so does the derivation of equation 4 immediately below it. With + /// a minus the radicand goes negative for ordinary inputs, which settles it. + /// + /// + /// The pool's balance of the asset being deposited, before the deposit. + /// How much of it is being deposited. + /// The pool's LP token balance, before the deposit. + /// The fee this depositor trades at - see . + /// A balance is not positive, the deposit is negative, or the fee exceeds . + public static decimal LPTokensForSingleAssetDeposit( + decimal poolBalance, + decimal deposit, + decimal lpTokenBalance, + uint tradingFee) + { + RequirePositive(poolBalance, nameof(poolBalance)); + RequirePositive(lpTokenBalance, nameof(lpTokenBalance)); + RequireNotNegative(deposit, nameof(deposit)); + RequireValidTradingFee(tradingFee); + + if (deposit == 0m) + { + return 0m; + } + + decimal fee = TradingFeeFraction(tradingFee); + decimal f1 = 1m - fee; + decimal f2 = (1m - fee / 2m) / f1; + decimal r = deposit / poolBalance; + decimal c = Sqrt(f2 * f2 + r / f1) - f2; + + return lpTokenBalance * (r - c) / (1m + c); + } + + /// + /// LP tokens spent to withdraw one asset only. + /// + /// + /// Equation 7: with fr = b/B and c = fr·fee + 2 − fee, + /// + /// t = T · (c − √(c² − 4·fr)) / 2 + /// + /// Note that this one uses the fee itself where + /// uses 1 − fee; rippled's lpTokensIn calls getFee rather than + /// feeMult, and the difference is easy to lose when transcribing. + /// The node rounds the last multiplication up here rather than down - both directions go + /// against the caller - so a withdrawal costs this or a shade more. + /// + /// The pool's balance of the asset being withdrawn, before the withdrawal. + /// How much of it is being withdrawn. + /// The pool's LP token balance, before the withdrawal. + /// The fee this account trades at - see . + /// A balance is not positive, the amount is negative, it exceeds the pool, or the fee exceeds . + public static decimal LPTokensForSingleAssetWithdraw( + decimal poolBalance, + decimal withdraw, + decimal lpTokenBalance, + uint tradingFee) + { + RequirePositive(poolBalance, nameof(poolBalance)); + RequirePositive(lpTokenBalance, nameof(lpTokenBalance)); + RequireNotNegative(withdraw, nameof(withdraw)); + RequireValidTradingFee(tradingFee); + + if (withdraw == 0m) + { + return 0m; + } + + if (withdraw > poolBalance) + { + throw new ArgumentOutOfRangeException( + nameof(withdraw), + $"Cannot withdraw {withdraw} from a pool holding {poolBalance}."); + } + + decimal fee = TradingFeeFraction(tradingFee); + decimal fr = withdraw / poolBalance; + decimal c = fr * fee + 2m - fee; + + return lpTokenBalance * (c - Sqrt(c * c - 4m * fr)) / 2m; + } + + /// + /// How much of one asset must be deposited to be credited exactly this many LP tokens. + /// + /// + /// + /// Equation 4, which is rippled solving equation 3 for b. With f1 and + /// f2 as in , t1 = t/T and + /// t2 = 1 + t1: + /// + /// + /// d = f2 - t1/t2 + /// a = 1/t2², b = 2·d/t2 - 1/f1, c = d² - f2² + /// deposit = B · (-b + √(b² - 4ac)) / 2a + /// + /// + /// The root is always real: swept across every fee up to the cap and token ratios from + /// 1e-6 to 1000, the discriminant never falls below 1, so the quadratic cannot hand + /// a negative and rippled's solveQuadraticEq does not guard it + /// either. + /// + /// + /// This is what an AMMDeposit carrying LPTokenOut will actually take from + /// the account, and the direction of the node's rounding reverses here: it maximizes the + /// deposit, so it takes this much or a shade more. That is consistent rather than + /// contrary - every one of these roundings favours the pool. + /// + /// + /// The pool's balance of the asset being deposited. + /// The LP tokens wanted. + /// The pool's LP token balance, before the deposit. + /// The fee this depositor trades at - see . + /// A balance is not positive, the token amount is negative, or the fee exceeds . + public static decimal SingleAssetDepositForLPTokens( + decimal poolBalance, + decimal lpTokens, + decimal lpTokenBalance, + uint tradingFee) + { + RequirePositive(poolBalance, nameof(poolBalance)); + RequirePositive(lpTokenBalance, nameof(lpTokenBalance)); + RequireNotNegative(lpTokens, nameof(lpTokens)); + RequireValidTradingFee(tradingFee); + + if (lpTokens == 0m) + { + return 0m; + } + + decimal fee = TradingFeeFraction(tradingFee); + decimal f1 = 1m - fee; + decimal f2 = (1m - fee / 2m) / f1; + decimal t1 = lpTokens / lpTokenBalance; + decimal t2 = 1m + t1; + decimal d = f2 - t1 / t2; + decimal a = 1m / (t2 * t2); + decimal b = 2m * d / t2 - 1m / f1; + decimal c = d * d - f2 * f2; + + return poolBalance * SolveQuadratic(a, b, c); + } + + /// + /// How much of one asset comes out for redeeming exactly this many LP tokens. + /// + /// + /// + /// Equation 8, rippled solving equation 7 for b. With t1 = t/T: + /// + /// + /// withdraw = B · (t1² - t1·(2 - fee)) / (t1·fee - 1) + /// + /// + /// Both halves of that fraction are negative for any real input, which is why the result + /// is not. What an AMMWithdraw carrying LPTokenIn pays out; the node + /// minimizes the withdrawal, so it pays this or a shade less. + /// + /// + /// The pool's balance of the asset being withdrawn. + /// The LP tokens being redeemed. + /// The pool's LP token balance, before the withdrawal. + /// The fee this account trades at - see . + /// A balance is not positive, the token amount is negative or exceeds the pool's, or the fee exceeds . + public static decimal SingleAssetWithdrawForLPTokens( + decimal poolBalance, + decimal lpTokens, + decimal lpTokenBalance, + uint tradingFee) + { + RequirePositive(poolBalance, nameof(poolBalance)); + RequirePositive(lpTokenBalance, nameof(lpTokenBalance)); + RequireNotNegative(lpTokens, nameof(lpTokens)); + RequireValidTradingFee(tradingFee); + + if (lpTokens == 0m) + { + return 0m; + } + + if (lpTokens > lpTokenBalance) + { + throw new ArgumentOutOfRangeException( + nameof(lpTokens), + $"Cannot redeem {lpTokens} tokens against a balance of {lpTokenBalance}."); + } + + decimal fee = TradingFeeFraction(tradingFee); + decimal t1 = lpTokens / lpTokenBalance; + + return poolBalance * (t1 * t1 - t1 * (2m - fee)) / (t1 * fee - 1m); + } + + /// + /// LP tokens credited for depositing both assets at the pool's own ratio. + /// + /// + /// A deposit in proportion does not move the price, so no fee applies: the node credits + /// T · frac and takes A · frac and B · frac, where frac is the + /// smaller of the two ratios offered - whichever asset runs out first decides how much of + /// the other is used. Use to find out how much of + /// each will actually be taken. + /// + /// A balance is not positive, or an amount is negative. + public static decimal LPTokensForProportionalDeposit( + decimal poolBalance1, + decimal poolBalance2, + decimal deposit1, + decimal deposit2, + decimal lpTokenBalance) + { + RequirePositive(poolBalance1, nameof(poolBalance1)); + RequirePositive(poolBalance2, nameof(poolBalance2)); + RequirePositive(lpTokenBalance, nameof(lpTokenBalance)); + RequireNotNegative(deposit1, nameof(deposit1)); + RequireNotNegative(deposit2, nameof(deposit2)); + + decimal frac = Math.Min(deposit1 / poolBalance1, deposit2 / poolBalance2); + return lpTokenBalance * frac; + } + + /// + /// How much of each asset a proportional deposit will actually take. + /// + /// + /// The leftover of the more plentiful asset stays where it is; the node deposits both sides + /// at the same fraction of the pool. + /// + public static (decimal Asset1, decimal Asset2) AssetsForProportionalDeposit( + decimal poolBalance1, + decimal poolBalance2, + decimal deposit1, + decimal deposit2) + { + RequirePositive(poolBalance1, nameof(poolBalance1)); + RequirePositive(poolBalance2, nameof(poolBalance2)); + RequireNotNegative(deposit1, nameof(deposit1)); + RequireNotNegative(deposit2, nameof(deposit2)); + + decimal frac = Math.Min(deposit1 / poolBalance1, deposit2 / poolBalance2); + return (poolBalance1 * frac, poolBalance2 * frac); + } + + /// + /// What redeeming LP tokens returns when both assets are taken out at the pool's ratio. + /// + /// + /// Equations 1 and 2: a = (t/T)·A and b = (t/T)·B. No fee, for the same + /// reason as a proportional deposit - the price does not move. + /// + /// A balance is not positive, the token amount is negative, or it exceeds the pool's. + public static (decimal Asset1, decimal Asset2) AssetsForProportionalWithdraw( + decimal poolBalance1, + decimal poolBalance2, + decimal lpTokens, + decimal lpTokenBalance) + { + RequirePositive(poolBalance1, nameof(poolBalance1)); + RequirePositive(poolBalance2, nameof(poolBalance2)); + RequirePositive(lpTokenBalance, nameof(lpTokenBalance)); + RequireNotNegative(lpTokens, nameof(lpTokens)); + + if (lpTokens > lpTokenBalance) + { + throw new ArgumentOutOfRangeException( + nameof(lpTokens), + $"Cannot redeem {lpTokens} tokens against a balance of {lpTokenBalance}."); + } + + decimal frac = lpTokens / lpTokenBalance; + return (poolBalance1 * frac, poolBalance2 * frac); + } + + /// + /// What comes out of the pool for swapping this much of the other asset in. + /// + /// + /// + /// rippled's swapAssetIn, and what a payment routed through an AMM pays the taker. + /// The node writes it as + /// + /// + /// out = poolOut - (poolIn · poolOut) / (poolIn + in·(1 - fee)) + /// + /// + /// which is the form used here, rearranged to poolOut·x/(poolIn + x) with + /// x = in·(1 - fee). The two are the same expression; the difference is that the + /// node's form subtracts two nearly equal numbers for a small swap and loses digits to + /// the cancellation, while this one has nothing to cancel. + /// + /// + /// The fee comes off the input before the curve sees it, so the whole of + /// still enters the pool - the fee stays there for the + /// liquidity providers rather than being taken away. + /// + /// + /// The pool's balance of the asset being swapped in. + /// The pool's balance of the asset being swapped out. + /// How much is being swapped in, fee included. + /// The fee this account trades at - see . + /// A balance is not positive, the amount is negative, or the fee exceeds . + public static decimal SwapAssetIn( + decimal poolIn, + decimal poolOut, + decimal assetIn, + uint tradingFee) + { + RequirePositive(poolIn, nameof(poolIn)); + RequirePositive(poolOut, nameof(poolOut)); + RequireNotNegative(assetIn, nameof(assetIn)); + RequireValidTradingFee(tradingFee); + + if (assetIn == 0m) + { + return 0m; + } + + decimal effectiveIn = assetIn * (1m - TradingFeeFraction(tradingFee)); + + return poolOut * effectiveIn / (poolIn + effectiveIn); + } + + /// + /// What must be swapped in to take exactly this much of the other asset out. + /// + /// + /// + /// rippled's swapAssetOut, the inverse of : + /// + /// + /// in = ((poolIn · poolOut) / (poolOut - out) - poolIn) / (1 - fee) + /// + /// + /// rearranged here to poolIn·out / ((poolOut - out)·(1 - fee)) for the same reason + /// as above. The cost climbs without bound as approaches the + /// pool's balance, which is the constant product refusing to be emptied; asking for the + /// whole of it, or more, is rejected rather than answered with a division by zero. + /// + /// + /// The pool's balance of the asset being swapped in. + /// The pool's balance of the asset being swapped out. + /// How much is wanted out. + /// The fee this account trades at - see . + /// A balance is not positive, the amount is negative or is not less than the pool, or the fee exceeds . + public static decimal SwapAssetOut( + decimal poolIn, + decimal poolOut, + decimal assetOut, + uint tradingFee) + { + RequirePositive(poolIn, nameof(poolIn)); + RequirePositive(poolOut, nameof(poolOut)); + RequireNotNegative(assetOut, nameof(assetOut)); + RequireValidTradingFee(tradingFee); + + if (assetOut == 0m) + { + return 0m; + } + + if (assetOut >= poolOut) + { + throw new ArgumentOutOfRangeException( + nameof(assetOut), + $"A constant-product pool cannot be emptied: {assetOut} was asked of a balance " + + $"of {poolOut}, and the cost of the last unit is unbounded."); + } + + return poolIn * assetOut / ((poolOut - assetOut) * (1m - TradingFeeFraction(tradingFee))); + } + + /// + /// Square root in , by Newton's method. + /// + /// + /// works in , whose 15 significant digits would + /// discard the precision the rest of this class keeps. The iteration is seeded from the + /// double result, which is already close, so it converges in a handful of steps; it stops + /// when the estimate settles or begins alternating between two neighbouring values, which + /// is how a decimal iteration ends when it can get no closer. + /// + /// is negative. + internal static decimal Sqrt(decimal value) + { + if (value < 0m) + { + throw new ArgumentOutOfRangeException(nameof(value), "Cannot take the square root of a negative number."); + } + + if (value == 0m) + { + return 0m; + } + + decimal guess; + try + { + guess = (decimal)Math.Sqrt((double)value); + } + catch (OverflowException) + { + guess = value; + } + + if (guess <= 0m) + { + guess = value > 1m ? value / 2m : 1m; + } + + decimal previous = 0m; + for (int step = 0; step < 100; step++) + { + decimal next = (guess + value / guess) / 2m; + if (next == guess || next == previous) + { + return next; + } + + previous = guess; + guess = next; + } + + return guess; + } + + /// + /// The larger root, which is the one rippled's solveQuadraticEq takes. + /// + private static decimal SolveQuadratic(decimal a, decimal b, decimal c) + => (-b + Sqrt(b * b - 4m * a * c)) / (2m * a); + + private static void RequireValidTradingFee(uint tradingFee) + { + if (tradingFee > TradingFeeThreshold) + { + throw new ArgumentOutOfRangeException( + nameof(tradingFee), + $"A trading fee is in units of 1/{TradingFeeScale}, so it cannot exceed " + + $"{TradingFeeThreshold} - one per cent - but was {tradingFee}."); + } + } + + private static void RequirePositive(decimal value, string name) + { + if (value <= 0m) + { + throw new ArgumentOutOfRangeException(name, $"{name} must be positive, but was {value}."); + } + } + + private static void RequireNotNegative(decimal value, string name) + { + if (value < 0m) + { + throw new ArgumentOutOfRangeException(name, $"{name} must not be negative, but was {value}."); + } + } + } +} diff --git a/Xrpl/Sugar/Autofill.cs b/Xrpl/Sugar/Autofill.cs index 92d42be1..9dc812a9 100644 --- a/Xrpl/Sugar/Autofill.cs +++ b/Xrpl/Sugar/Autofill.cs @@ -186,15 +186,38 @@ public static async Task SetNextValidSequenceNumber(this IXrplClient clien { LedgerIndex index = new LedgerIndex(LedgerIndexType.Current); AccountInfoRequest request = new AccountInfoRequest((string)tx["Account"]) { LedgerIndex = index }; - AccountInfo data = await client.AccountInfo(request, cancellationToken); - tx.TryAdd("Sequence", data.AccountData.Sequence); - return data.AccountData.Sequence; + AccountInfo data = await client.AccountInfo(request, cancellationToken).Typed(); + // account_info returns the full AccountRoot for a live "current" ledger request, so + // AccountData (and its Sequence) is always present; a missing AccountData or Sequence + // means the node response is malformed and should fail loudly rather than dereference + // a null AccountData or silently autofill 0. + if (data.AccountData is null) + { + throw new XrplException("account_info response did not include account_data."); + } + + uint? sequence = data.AccountData.Sequence; + if (sequence == null) + { + throw new XrplException("account_info response did not include the account's Sequence."); + } + tx.TryAdd("Sequence", sequence.Value); + return sequence.Value; } public static async Task FetchReserveFee(this IXrplClient client, CancellationToken cancellationToken = default) { ServerStateRequest request = new ServerStateRequest(); - ServerState data = await client.ServerState(request, cancellationToken); + ServerState data = await client.ServerState(request, cancellationToken).Typed(); + + // Checked before dereferencing, not after: reading through State.ValidatedLedger and + // testing only the leaf turns a response missing either container into a + // NullReferenceException, which says nothing about what the node returned. + if (data.State?.ValidatedLedger is null) + { + throw new XrplException("server_state response did not include the validated ledger."); + } + uint? fee = data.State.ValidatedLedger.ReserveInc; if (fee == null) @@ -339,7 +362,7 @@ private static async Task FetchCounterpartySignerCount(IXrplClient client, try { - AccountInfo data = await client.AccountInfo(request, cancellationToken); + AccountInfo data = await client.AccountInfo(request, cancellationToken).Typed(); int? entries = data?.SignerLists?.Length > 0 ? data.SignerLists[0].SignerEntries?.Count : null; return entries is > 0 ? entries.Value : 1; } @@ -425,7 +448,7 @@ private static async Task FetchLoan(IXrplClient client, string loanId, C Index = loanId, LedgerIndex = new LedgerIndex(LedgerIndexType.Current), }; - LedgerEntryResponse response = await client.LedgerEntry(request, cancellationToken); + LedgerEntryResponse response = await client.LedgerEntry(request, cancellationToken).Typed(); return response?.Node as LOLoan; } catch (Exception) when (!cancellationToken.IsCancellationRequested) @@ -664,7 +687,7 @@ public static async Task CheckAccountDeleteBlockers(this IXrplClient client, Dic LedgerIndex = index, DeletionBlockersOnly = true, }; - AccountObjects response = await client.AccountObjects(request, cancellationToken); + AccountObjects response = await client.AccountObjects(request, cancellationToken).Typed(); TaskCompletionSource task = new TaskCompletionSource(); if (response.AccountObjectList.Count > 0) { diff --git a/Xrpl/Sugar/Balances.cs b/Xrpl/Sugar/Balances.cs index def730f3..9cf0f524 100644 --- a/Xrpl/Sugar/Balances.cs +++ b/Xrpl/Sugar/Balances.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using Xrpl.Client; +using Xrpl.Client.Exceptions; using Xrpl.Models.Common; using Xrpl.Models.Ledger; using Xrpl.Models.Methods; @@ -58,7 +59,15 @@ public static async Task GetXrpBalance(this IXrplClient client, string a LedgerIndex = lederIndex ?? index, Strict = true }; - AccountInfo accountInfo = await client.AccountInfo(xrpRequest, cancellationToken); + AccountInfo accountInfo = await client.AccountInfo(xrpRequest, cancellationToken).Typed(); + + // account_info for a live account always returns account_data; a missing value means a + // malformed node response and must fail loudly rather than dereference a null AccountData. + if (accountInfo.AccountData is null) + { + throw new ValidationException($"account_info response for '{address}' did not include account_data."); + } + return accountInfo.AccountData.Balance.ValueAsXrp.ToString(); } @@ -86,22 +95,52 @@ public static async Task GetXrpFreeBalance(this IXrplClient client, str LedgerIndex = lederIndex ?? index, Strict = true }; - AccountInfo accountInfo = await client.AccountInfo(xrpRequest, cancellationToken); + AccountInfo accountInfo = await client.AccountInfo(xrpRequest, cancellationToken).Typed(); + + // account_info for a live account always returns account_data; a missing value means a + // malformed node response and must fail loudly rather than dereference a null AccountData. + if (accountInfo.AccountData is null) + { + throw new ValidationException($"account_info response for '{address}' did not include account_data."); + } + + var serverInfo = await client.ServerState(new ServerStateRequest(), cancellationToken).Typed(); + + // server_state for a live node always returns validated_ledger.reserve_base/reserve_inc; + // a missing value means a malformed node response and must fail loudly rather than be + // treated as a zero reserve, which would overstate the free balance. + // The containers are checked first: testing only the leaf values, as this did, means a + // response missing "state" or "validated_ledger" faults with a NullReferenceException + // on the way to the check rather than reaching it. + if (serverInfo.State?.ValidatedLedger is null) + { + throw new ValidationException("server_state response did not include the validated ledger."); + } + + uint? reserveInc = serverInfo.State.ValidatedLedger.ReserveInc; + uint? reserveBase = serverInfo.State.ValidatedLedger.ReserveBase; + if (reserveInc == null || reserveBase == null) + { + throw new ValidationException("server_state response did not include the validated ledger's reserve_base/reserve_inc."); + } - var serverInfo = await client.ServerState(new ServerStateRequest(), cancellationToken); - var FlineReserveFee = serverInfo.State.ValidatedLedger.ReserveInc.ToString(); - var FaccReserveFee = serverInfo.State.ValidatedLedger.ReserveBase.ToString(); var lineReserveFee = (decimal)new Currency() { - Value = FlineReserveFee + Value = reserveInc.Value.ToString() }.ValueAsXrp; var accReserveFee = (decimal)new Currency() { - Value = FaccReserveFee + Value = reserveBase.Value.ToString() }.ValueAsXrp; - var numLines = accountInfo.AccountData.OwnerCount; - var totalReserve = accReserveFee + (lineReserveFee * numLines); + // account_info for a live account always returns OwnerCount; a missing value means a malformed + // node response and must fail loudly rather than be treated as 0 owned objects. + uint? numLines = accountInfo.AccountData.OwnerCount; + if (numLines == null) + { + throw new ValidationException($"account_info response for '{address}' did not include the account's OwnerCount."); + } + var totalReserve = accReserveFee + (lineReserveFee * numLines.Value); var freeBalance = (decimal)accountInfo.AccountData.Balance.ValueAsXrp - totalReserve; return freeBalance; } @@ -118,12 +157,12 @@ public static async Task> GetBalances(this IXrplClient client, str Limit = options?.Limit }; - var response = await client.AccountLines(linesRequest, cancellationToken); + var response = await client.AccountLines(linesRequest, cancellationToken).Typed(); var lines = response.TrustLines; while (response.Marker is not null && lines.Count > 0) { linesRequest.Marker = response.Marker; - response = await client.AccountLines(linesRequest, cancellationToken); + response = await client.AccountLines(linesRequest, cancellationToken).Typed(); if (response.TrustLines.Count > 0) lines.AddRange(response.TrustLines); if (options?.Limit is not null && lines.Count >= options.Limit) diff --git a/Xrpl/Sugar/ComposeSugar.cs b/Xrpl/Sugar/ComposeSugar.cs index 9b6a4196..a3cf246b 100644 --- a/Xrpl/Sugar/ComposeSugar.cs +++ b/Xrpl/Sugar/ComposeSugar.cs @@ -121,8 +121,14 @@ private static void ValidateQuorum(LOSignerList? list, JsonArray? signers, strin } } - if (collected < list.SignerQuorum) - throw new ValidationException($"Insufficient signatures for the {side} SignerList: collected weight {collected} of the required quorum {list.SignerQuorum}."); + // SignerQuorum is a required field of a live SignerList ledger entry (never legitimately absent); + // treat a missing value as a malformed fetch rather than silently skip the quorum check + // (collected < null is always false, which would defeat the whole point of failing fast here). + if (list.SignerQuorum is not { } quorum) + throw new ValidationException($"SignerList for the {side} side is missing SignerQuorum; cannot validate collected signatures."); + + if (collected < quorum) + throw new ValidationException($"Insufficient signatures for the {side} SignerList: collected weight {collected} of the required quorum {quorum}."); } /// @@ -150,7 +156,7 @@ internal static async Task> GetSignerListAccounts( { Type = LedgerEntryType.SignerList, }; - AccountObjects response = await client.AccountObjects(request, cancellationToken).ConfigureAwait(false); + AccountObjects response = await client.AccountObjects(request, cancellationToken).Typed().ConfigureAwait(false); return response?.AccountObjectList?.OfType().FirstOrDefault(); } diff --git a/Xrpl/Sugar/DomainAccess.cs b/Xrpl/Sugar/DomainAccess.cs index ef73423d..eded8a21 100644 --- a/Xrpl/Sugar/DomainAccess.cs +++ b/Xrpl/Sugar/DomainAccess.cs @@ -78,8 +78,19 @@ public static async Task GetDomainAccess(this IXrplClient cl // read-path check must compare Expiration itself instead of trusting // the entry's existence. LedgerRequest ledgerRequest = new LedgerRequest { LedgerIndex = new LedgerIndex(LedgerIndexType.Validated) }; - LOLedger ledgerResponse = await client.Ledger(ledgerRequest, cancellationToken); - LedgerEntity ledger = (LedgerEntity)ledgerResponse.LedgerEntity; + LOLedger ledgerResponse = await client.Ledger(ledgerRequest, cancellationToken).Typed(); + // LedgerEntity is interface-typed, and the cast has two ways to go wrong that look + // nothing alike at the call site: a response without a "ledger" member casts to null + // and faults on the next dereference, while a binary response deserializes to + // LedgerBinaryEntity and throws InvalidCastException. Both are protocol conditions and + // should read as such. + if (ledgerResponse.LedgerEntity is not LedgerEntity ledger) + { + throw new ValidationException( + "Validated ledger response did not include a JSON ledger object" + + (ledgerResponse.LedgerEntity is null ? "." : " - got " + ledgerResponse.LedgerEntity.GetType().Name + ", which a binary request produces.")); + } + uint ledgerIndex = Convert.ToUInt32(ledger.LedgerIndex); DateTime closeTime = ledger.CloseTime ?? throw new RippleException("Validated ledger response did not include a close time."); @@ -90,7 +101,7 @@ public static async Task GetDomainAccess(this IXrplClient cl Index = domainId, LedgerIndex = pinnedIndex }; - LedgerEntryResponse domainResponse = await client.LedgerEntry(domainRequest, cancellationToken); + LedgerEntryResponse domainResponse = await client.LedgerEntry(domainRequest, cancellationToken).Typed(); if (domainResponse.Node is not LOPermissionedDomain domain) throw new RippleException($"Ledger entry {domainId} is not a PermissionedDomain."); @@ -119,7 +130,12 @@ public static DomainAccessResult EvaluateDomainAccess(IReadOnlyList expiration; if (accepted && !expired) { @@ -154,7 +170,7 @@ private static async Task LookupCredential(IXrplClient client, str }; try { - LedgerEntryResponse response = await client.LedgerEntry(request, cancellationToken); + LedgerEntryResponse response = await client.LedgerEntry(request, cancellationToken).Typed(); return response.Node as LOCredential; } catch (RippledException ex) when (ex.Response?.Error == XrplErrorCodes.EntryNotFound) diff --git a/Xrpl/Sugar/GetFeeXrp.cs b/Xrpl/Sugar/GetFeeXrp.cs index d15f6fe4..a4df6d9f 100644 --- a/Xrpl/Sugar/GetFeeXrp.cs +++ b/Xrpl/Sugar/GetFeeXrp.cs @@ -27,7 +27,7 @@ public static async Task GetFeeXrp(this IXrplClient client, double? cush { double feeCushion = cushion ?? client.feeCushion; ServerInfoRequest request = new ServerInfoRequest(); - ServerInfo serverInfo = await client.ServerInfo(request, cancellationToken); + ServerInfo serverInfo = await client.ServerInfo(request, cancellationToken).Typed(); decimal? baseFee = serverInfo.Info.ValidatedLedger?.BaseFeeXrp; if (baseFee == null) { diff --git a/Xrpl/Sugar/GetLedgerIndex.cs b/Xrpl/Sugar/GetLedgerIndex.cs index f214c69a..bc21643a 100644 --- a/Xrpl/Sugar/GetLedgerIndex.cs +++ b/Xrpl/Sugar/GetLedgerIndex.cs @@ -7,6 +7,7 @@ using Xrpl.Models.Ledger; using Xrpl.Models.Methods; +using Xrpl.Client.Exceptions; // https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/src/sugar/getLedgerIndex.ts namespace Xrpl.Sugar @@ -22,8 +23,16 @@ public static async Task GetLedgerIndex(this IXrplClient client, Cancellat { LedgerIndex index = new LedgerIndex(LedgerIndexType.Current); LedgerRequest request = new LedgerRequest() { LedgerIndex = index }; - LOLedger ledgerResponse = await client.Ledger(request, cancellationToken); - LedgerEntity ledger = (LedgerEntity)ledgerResponse.LedgerEntity; + LOLedger ledgerResponse = await client.Ledger(request, cancellationToken).Typed(); + // See DomainAccess for why this is checked rather than cast: a missing "ledger" member + // casts to null and faults later, a binary response is a different concrete type. + if (ledgerResponse.LedgerEntity is not LedgerEntity ledger) + { + throw new ValidationException( + "Ledger response did not include a JSON ledger object" + + (ledgerResponse.LedgerEntity is null ? "." : " - got " + ledgerResponse.LedgerEntity.GetType().Name + ", which a binary request produces.")); + } + return Convert.ToUInt32(ledger.LedgerIndex); } } diff --git a/Xrpl/Sugar/GetOrderBook.cs b/Xrpl/Sugar/GetOrderBook.cs index ea07d420..0e603b9e 100644 --- a/Xrpl/Sugar/GetOrderBook.cs +++ b/Xrpl/Sugar/GetOrderBook.cs @@ -37,10 +37,10 @@ public static class GetOrderBookSugar Limit = limit, Taker = taker }; - var directOfferResults = await Client.BookOffers(request, cancellationToken); + var directOfferResults = await Client.BookOffers(request, cancellationToken).Typed(); request.TakerGets = takerPays; request.TakerPays = takerGets; - var reverseOfferResults = await Client.BookOffers(request, cancellationToken); + var reverseOfferResults = await Client.BookOffers(request, cancellationToken).Typed(); var directOffers = directOfferResults.Offers; var reverseOffers = reverseOfferResults.Offers; var orders = directOffers.Concat(reverseOffers).ToList(); diff --git a/Xrpl/Sugar/Submit.cs b/Xrpl/Sugar/Submit.cs index 718881c9..f3a596df 100644 --- a/Xrpl/Sugar/Submit.cs +++ b/Xrpl/Sugar/Submit.cs @@ -51,7 +51,7 @@ public static async Task Submit( CancellationToken cancellationToken = default ) { - var (signedTx, _) = await client.GetSignedTx(transaction, autofill, failHard: false, wallet, cancellationToken); + var (signedTx, _) = await client.GetSignedTx(transaction, autofill, wallet, cancellationToken); return await SubmitRequest(client, signedTx, failHard, cancellationToken); } @@ -93,7 +93,7 @@ public static async Task SubmitAndWait( bool failHard = false, CancellationToken cancellationToken = default) { - var (signedTx, tx) = await client.GetSignedTx(transaction, autofill, failHard, wallet, cancellationToken); + var (signedTx, tx) = await client.GetSignedTx(transaction, autofill, wallet, cancellationToken); var lastLedger = GetLastLedgerSequence(tx); if (lastLedger == null) { @@ -149,7 +149,7 @@ public static async Task SubmitRequest(this IXrplClient client, object s TxBlob = signedTxEncoded, FailHard = failHard, }; - var response = await client.GRequest(request, cancellationToken); + var response = await client.GRequest(request, cancellationToken).Typed(); return response; } @@ -290,14 +290,28 @@ public static async Task SubmitMultiBatch( new AccountInfoRequest(acct) { SignerLists = true - }, cancellationToken); - var hasSL = ai.SignerLists?.Length > 0 && ai.AccountFlags!.DisableMasterKey; + }, cancellationToken).Typed(); + // Both are read below to decide how this account signs, and a response missing either + // is malformed rather than a signer with no flags: the master-key check would otherwise + // throw NullReferenceException here, and the RegularKey lookup further down would do + // the same. Failing with the account named beats either. + if (ai.AccountData is null) + { + throw new ValidationException($"account_info response for '{acct}' did not include account_data."); + } + + if (ai.AccountFlags is null) + { + throw new ValidationException($"account_info response for '{acct}' did not include the account's flags."); + } + + var hasSL = ai.SignerLists?.Length > 0 && ai.AccountFlags.DisableMasterKey; if (hasSL) { var sl = ai.SignerLists[0]; - var (picked, sum) = BatchSigningHelper.PickWalletsForQuorum(sl, walletByAddr); + var (picked, sum, quorum) = BatchSigningHelper.PickWalletsForQuorum(sl, walletByAddr); - if (sum < sl.SignerQuorum) + if (sum < quorum) { throw new ValidationException($"Not enough signer wallets for multisig account {acct}."); } @@ -308,7 +322,7 @@ public static async Task SubmitMultiBatch( } else { - if (walletByAddr.TryGetValue(acct, out var owner) && !ai.AccountFlags!.DisableMasterKey) + if (walletByAddr.TryGetValue(acct, out var owner) && !ai.AccountFlags.DisableMasterKey) partialBlobs.Add(owner.SignAsBatchPart(txJson, multisign: false, signingFor: acct).TxBlob); else if (!string.IsNullOrEmpty(ai.AccountData.RegularKey) && walletByAddr.TryGetValue(ai.AccountData.RegularKey, out var rk)) @@ -326,8 +340,15 @@ public static async Task SubmitMultiBatch( new AccountInfoRequest(mainAcc) { SignerLists = true - }, cancellationToken); - var rootHasSL = aiRoot.SignerLists?.Length > 0 && aiRoot.AccountFlags!.DisableMasterKey; + }, cancellationToken).Typed(); + // Same shape as the per-account check above: the master-key flag decides how the root + // signs, and a response without flags is malformed rather than an account with none. + if (aiRoot.AccountFlags is null) + { + throw new ValidationException($"account_info response for '{mainAcc}' did not include the account's flags."); + } + + var rootHasSL = aiRoot.SignerLists?.Length > 0 && aiRoot.AccountFlags.DisableMasterKey; if (!rootHasSL) { // обычная подпись плательщика комиссии (должен быть в wallets) @@ -342,9 +363,9 @@ public static async Task SubmitMultiBatch( { // мультисиг корня: берём из wallets только тех, кто входит в SignerList(main) var sl = aiRoot.SignerLists[0]; - var (picked, sum) = BatchSigningHelper.PickWalletsForQuorum(sl, walletByAddr); + var (picked, sum, quorum) = BatchSigningHelper.PickWalletsForQuorum(sl, walletByAddr); - if (sum < sl.SignerQuorum) throw new ValidationException($"Not enough signer wallets for root multisig {mainAcc}."); + if (sum < quorum) throw new ValidationException($"Not enough signer wallets for root multisig {mainAcc}."); //// корневой мультисиг: обязательно пустой SPK и без TxnSignature //combinedJson.Remove("TxnSignature"); @@ -420,7 +441,7 @@ private static async Task WaitForFinalTransactionOutcome( new TxRequest(txHash) { ApiVersion = 2, - }, cancellationToken); + }, cancellationToken).Typed(); } catch (RippledException ex) when (ex.Response?.Error == XrplErrorCodes.TxnNotFound) { @@ -452,15 +473,30 @@ private static async Task WaitForFinalTransactionOutcome( string txResult = txResponse.Meta?.TransactionResult; if (txResult != null && !txResult.StartsWith("tes") && txResult != "terQUEUED") { - throw new RippleException($"Final tx result is not success: {txResult}"); + // Applied to a ledger: the fee was taken and there is a transaction to look up, + // so the summary travels with the failure. The message is unchanged - what a + // caller needs in order to act sits beside it, not inside it. + throw new TransactionFailedException( + $"Final tx result is not success: {txResult}", + engineResult: txResult, + hash: txHash, + result: txResponse); } return txResponse; } if (submissionResult != "tesSUCCESS" && submissionResult != "terQUEUED") { - // Ошибочная транзакция тоже финальна, не валидируется сетью в большинстве случаев. - throw new RippleException($"Final tx result is not success: {submissionResult}"); + // Reached when the transaction is not validated yet and the node's provisional + // answer was already a failure. Final enough to stop waiting on - but not all the + // same kind of failure: a tem or a tef never reaches a ledger and costs nothing, + // while a tec was applied and the fee is gone, it simply has not been validated at + // the moment this is noticed. Hence no summary here for either, and hence + // ReachedLedger reading the code rather than the absence of one. + throw new TransactionFailedException( + $"Final tx result is not success: {submissionResult}", + engineResult: submissionResult, + hash: txHash); } // Не валидирована и не txnNotFound → просто ждём дальше после проверки текущего леджера @@ -479,16 +515,14 @@ private static async Task WaitForFinalTransactionOutcome( /// Initializes a transaction for a submit request /// /// A Client. - /// A transaction to autofill, sign & encode, and submit. + /// A transaction to autofill, sign and encode. /// If true, autofill a transaction. - /// If true, and the transaction fails locally, do not retry or relay the transaction to other servers. /// A wallet to sign a transaction. It must be provided when submitting an unsigned transaction. - /// A Wallet derived from a seed. + /// The signed transaction blob and the transaction it was built from. public static async Task<(string txBlob, Dictionary tx)> GetSignedTx( this IXrplClient client, Dictionary transaction, bool autofill = false, - bool failHard = false, XrplWallet? wallet = null, CancellationToken cancellationToken = default, bool sponsorPreCheck = true @@ -625,15 +659,17 @@ internal static async Task IsSponsorSignatureRequired( { Type = Models.LedgerEntryType.Sponsorship, }; - var response = await client.AccountObjects(request, cancellationToken).ConfigureAwait(false); + var response = await client.AccountObjects(request, cancellationToken).Typed().ConfigureAwait(false); var sponsorship = response?.AccountObjectList? .OfType() .FirstOrDefault(s => string.Equals(s.Sponsee, account, StringComparison.Ordinal)); if (sponsorship is null) return false; - bool requireForFee = sponsorship.Flags.HasFlag(Models.Ledger.SponsorshipFlags.lsfSponsorshipRequireSignForFee); - bool requireForReserve = sponsorship.Flags.HasFlag(Models.Ledger.SponsorshipFlags.lsfSponsorshipRequireSignForReserve); + // A missing Flags value is equivalent to "no flags set" for a bitmask check, so false is the correct + // (not a fabricated) default here - unlike numeric fields (Sequence, balances) where 0 would be a lie. + bool requireForFee = sponsorship.Flags?.HasFlag(Models.Ledger.SponsorshipFlags.lsfSponsorshipRequireSignForFee) ?? false; + bool requireForReserve = sponsorship.Flags?.HasFlag(Models.Ledger.SponsorshipFlags.lsfSponsorshipRequireSignForReserve) ?? false; return ((coverage & (uint)SponsorCoverage.spfSponsorFee) != 0 && requireForFee) || ((coverage & (uint)SponsorCoverage.spfSponsorReserve) != 0 && requireForReserve); diff --git a/Xrpl/Utils/Index.cs b/Xrpl/Utils/Index.cs index 4a3e7f4b..4aa556e7 100644 --- a/Xrpl/Utils/Index.cs +++ b/Xrpl/Utils/Index.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Text.Json.Nodes; using Xrpl.AddressCodec; @@ -57,9 +56,17 @@ public static bool IsValidAddress(string address) return XrplAddressCodec.IsValidXAddress(address) || XrplCodec.IsValidClassicAddress(address); } + /// + /// True when the node reported a marker, meaning more pages follow. + /// + /// + /// Read off the raw result rather than a parsed projection: the previous form compared + /// against Dictionary<string, object>, which the member never was, so it + /// answered false for every response including paged ones. + /// public static bool HasNextPage(this BaseResponse response) { - return response.Result is Dictionary dict && dict.ContainsKey("marker"); + return response is not null && response.RawResult.HasTopLevelProperty("marker"u8); } } } diff --git a/Xrpl/Utils/XrpConversion.cs b/Xrpl/Utils/XrpConversion.cs index b1742acb..6f416574 100644 --- a/Xrpl/Utils/XrpConversion.cs +++ b/Xrpl/Utils/XrpConversion.cs @@ -75,7 +75,7 @@ public static class XrpConversion /// Convert Drops to XRP. /// /// Drops to convert to XRP. This can be a string, number, or BigNumber. - /// Amount in XRP. public static string DropsToXrp(double dropsToConvert) { return DropsToXrp(dropsToConvert.ToString(CultureInfo.InvariantCulture)); @@ -85,7 +85,7 @@ public static string DropsToXrp(double dropsToConvert) /// Convert Drops to XRP. ///
/// Drops to convert to XRP. This can be a string, number, or BigNumber. - /// Amount in XRP. public static string DropsToXrp(string dropsToConvert) { /* @@ -141,7 +141,7 @@ public static string DropsToXrp(string dropsToConvert) /// Convert XRP to Drops. ///
/// XRP to convert to Drops. This can be a string, number, or BigNumber. - /// Amount in drops. public static string XrpToDrops(double xrpToConvert) { return XrpToDrops(xrpToConvert.ToString()); diff --git a/Xrpl/Wallet/BatchSigningHelper.cs b/Xrpl/Wallet/BatchSigningHelper.cs index 251e530f..4f4f400f 100644 --- a/Xrpl/Wallet/BatchSigningHelper.cs +++ b/Xrpl/Wallet/BatchSigningHelper.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Text.Json.Nodes; +using Xrpl.Client.Exceptions; using Xrpl.Models.Ledger; namespace Xrpl.Wallet; @@ -127,12 +128,21 @@ public static void MergeBatchSigner(JsonObject target, JsonObject incoming) /// Picks wallets from a dictionary that satisfy the quorum of a SignerList. /// Wallets are selected by descending weight until the quorum is met. ///
- /// A tuple of (selected wallets, total weight achieved) - public static (List picked, uint totalWeight) PickWalletsForQuorum( + /// A tuple of (selected wallets, total weight achieved, the quorum they were selected against). + public static (List picked, uint totalWeight, uint quorum) PickWalletsForQuorum( LOSignerList signerList, IDictionary walletByAddr) { - var need = signerList.SignerQuorum; + // SignerQuorum is a required field of a live SignerList ledger entry (never legitimately absent); + // a null here means the caller passed a malformed/partial object, so fail loudly rather than let + // the quorum check below silently never trip and over-pick wallets. The resolved quorum is + // returned so callers compare against the exact value used here instead of re-reading + // signerList.SignerQuorum themselves, which would silently depend on this method having + // already validated it. + if (signerList.SignerQuorum is not { } need) + { + throw new ValidationException("SignerList is missing SignerQuorum; cannot determine quorum for wallet selection."); + } var candidates = signerList.SignerEntries .Select(se => (addr: se.SignerEntry.Account, w: se.SignerEntry.SignerWeight)) .OrderByDescending(x => x.w) @@ -151,7 +161,7 @@ public static (List picked, uint totalWeight) PickWalletsForQuorum( } } - return (picked, sum); + return (picked, sum, need); } } \ No newline at end of file diff --git a/Xrpl/Wallet/XrplWallet.cs b/Xrpl/Wallet/XrplWallet.cs index b48f519d..3d77652c 100644 --- a/Xrpl/Wallet/XrplWallet.cs +++ b/Xrpl/Wallet/XrplWallet.cs @@ -48,14 +48,53 @@ public Dictionary GetTxDictionary() return JsonSerializer.Deserialize>(dic.ToString(), XrplJsonOptions.Default); } + /// + /// The signed blob decoded back into a typed transaction. + /// + /// + /// The blob carries a top-level field no model property claims, so the returned object + /// would not represent it. Signing that object again produces a blob missing the field - + /// which is how a co-signature could be dropped: CounterpartySignature and + /// SponsorSignature exist in definitions.json and survive the codec, but no + /// request model declares them, so a round trip through here used to discard them + /// silently. Failing loudly is the point: the caller is told what would have been lost + /// rather than submitting a transaction the node will reject for a missing signature. + /// For those two flows use the blob-level helpers (LoanSigningHelper.BrokerSign, + /// SponsorSigningHelper.SubmitterSign), which never leave the blob. + /// public ITransactionRequest GetTx() { if (TxBlob == null) { throw new NullReferenceException(nameof(TxBlob)); } - return JsonSerializer.Deserialize( - XrplBinaryCodec.Decode(TxBlob).ToString(), XrplJsonOptions.Default); + + JsonObject decoded = XrplBinaryCodec.Decode(TxBlob).AsObject(); + ITransactionRequest transaction = JsonSerializer.Deserialize( + decoded.ToString(), XrplJsonOptions.Default); + + string reemitted = JsonSerializer.Serialize(transaction, transaction.GetType(), XrplJsonOptions.Default); + using JsonDocument roundTripped = JsonDocument.Parse(reemitted); + + List dropped = new List(); + foreach (KeyValuePair member in decoded) + { + if (!roundTripped.RootElement.TryGetProperty(member.Key, out _)) + { + dropped.Add(member.Key); + } + } + + if (dropped.Count > 0) + { + throw new ValidationException( + "Decoding this blob into a typed transaction would drop " + + string.Join(", ", dropped) + + " - no model property carries it, so signing the result would produce a blob without it. " + + "Work from TxBlob instead (see LoanSigningHelper/SponsorSigningHelper for the co-signing flows)."); + } + + return transaction; } } public enum TextWalletKdf @@ -568,6 +607,31 @@ private static string NormalizeText(string input, bool caseInsensitive) } + /// + /// Refuses a transaction whose memos a node would refuse locally, before it is signed. + /// + /// + /// + /// A memo past the limit fails rippled's passesLocalChecks: the transaction is not + /// relayed, reaches no ledger and costs no fee - but the consumer has by then built, + /// autofilled and signed it, and the node's answer does not say which field was at fault. + /// This is the last point where the refusal is still free. + /// + /// + /// Called from every public entry that signs a transaction dictionary rather than from + /// alone, which would not have + /// been enough: SignAsBatchPart, SignAsSponsor and + /// SignAsLoanCounterparty each sign on their own, and the SDK's own multi-batch + /// submission calls the first of them directly. The typed overloads convert and delegate to + /// these, so guarding the four dictionary ones covers every way in. + /// + /// + private static void GuardMemos(Dictionary transaction) + { + transaction.TryGetValue("Memos", out object memos); + MemoRules.Validate(memos); + } + /// /// Signs a transaction offline. /// @@ -575,8 +639,14 @@ private static string NormalizeText(string input, bool caseInsensitive) /// Specify true/false to use multisign or actual address (classic/x-address) to make multisign tx request. /// /// A Wallet derived from the seed. + /// + /// When the transaction carries Memos a node would refuse locally - see + /// . + /// public SignatureResult Sign(Dictionary transaction, bool multisign = false, string? signingFor = null) { + GuardMemos(transaction); + // 1) специальный кейс Batch inner-part if (string.Equals($"{transaction[nameof(ITransactionCommon.TransactionType)]}", "Batch", StringComparison.OrdinalIgnoreCase)) { @@ -740,6 +810,7 @@ public SignatureResult SignAsBatchPart(IBatch transaction, bool multisign, strin } public SignatureResult SignAsBatchPart(Dictionary transaction, bool multisign, string? signingFor) { + GuardMemos(transaction); VerifyBatchSubmitter(transaction, signingFor, false); // 1) Стандартизируем вход в JsonObject @@ -1072,9 +1143,14 @@ public string ComputeSignature(Dictionary transaction, string pr /// V3 (sequential) — borrower signs first, passes to broker: /// /// var withCounterparty = borrowerWallet.SignAsLoanCounterparty(preparedTx); - /// var final = brokerWallet.Sign(withCounterparty.GetTx()); + /// var final = LoanSigningHelper.BrokerSign(withCounterparty.TxBlob, brokerWallet); /// await client.SubmitRequest(final.TxBlob); /// + /// Note the blob, not GetTx(): no request model declares + /// CounterpartySignature, so decoding into a typed transaction and signing that + /// would produce a blob without the co-signature. BrokerSign stays at the blob + /// level, stripping the co-signature to compute the preimage and restoring it afterwards. + /// now refuses such a blob rather than losing it. /// /// V2 (parallel) — both sign independently, then combine: /// @@ -1097,6 +1173,8 @@ public SignatureResult SignAsLoanCounterparty(ITransactionRequest transaction) ///
public SignatureResult SignAsLoanCounterparty(Dictionary transaction) { + GuardMemos(transaction); + JsonObject tx = JsonNode.Parse(JsonSerializer.Serialize(transaction, XrplJsonOptions.Default))?.AsObject() ?? throw new ValidationException("Failed to serialize transaction to JSON"); @@ -1161,6 +1239,8 @@ public SignatureResult SignAsSponsor(ITransactionRequest transaction) ///
public SignatureResult SignAsSponsor(Dictionary transaction) { + GuardMemos(transaction); + JsonObject tx = JsonNode.Parse(JsonSerializer.Serialize(transaction, XrplJsonOptions.Default))?.AsObject() ?? throw new ValidationException("Failed to serialize transaction to JSON"); diff --git a/Xrpl/Xrpl.csproj b/Xrpl/Xrpl.csproj index 1c78a156..cf08d66b 100644 --- a/Xrpl/Xrpl.csproj +++ b/Xrpl/Xrpl.csproj @@ -14,7 +14,7 @@ Apache-2.0 https://github.com/StaticBit-io/XrplCSharp XrplCSharp - 10.12.0.0 + 11.0.0.0 diff --git a/plans/2026-08-17-raw-response-level0.md b/plans/2026-08-17-raw-response-level0.md new file mode 100644 index 00000000..1a813486 --- /dev/null +++ b/plans/2026-08-17-raw-response-level0.md @@ -0,0 +1,1249 @@ +# Raw Response, уровень 0: один парс и срез вместо JsonElement + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Убрать двойной разбор ответа и промежуточный `JsonElement`, сохранив вместо него границы поддерева `result` внутри уже существующего байтового кадра — чтобы уровень 1 мог отдать потребителю байт-точный исходный JSON без единой дополнительной аллокации. + +**Architecture:** `BaseResponse.Result` (тип `object`, который System.Text.Json заполняет самодостаточным `JsonElement` с невозвращаемой арендой из `ArrayPool`) заменяется на `JsonSlice` — пару (offset, length) внутри кадра. Границы снимает конвертер через `Utf8JsonReader.TokenStartIndex` + `Skip()` + `BytesConsumed`, не материализуя поддерево. Кадр — это уже существующий точный `new byte[]` из `WebSocketClient.ReceiveLoop`; `RequestManager` связывает его со слайсом и десериализует целевой тип **напрямую из среза**, одним парсом. + +**Tech Stack:** .NET 8/9/10, System.Text.Json, MSTest 4.0.2 (фильтр `TestU`), существующий стенд `Tests/Xrpl.Tests/Client/TestUResponseParsing.cs`. + +**Две ловушки тестового проекта, проверенные на практике:** +- `Assert.ThrowsException` в MSTest 4.0.2 **не существует** — в репозитории 26 использований `Assert.ThrowsExactly` и 39 `Assert.ThrowsExactlyAsync`. Использовать только их. +- `ImplicitUsings` в `Xrpl.Tests.csproj` выключен: `using System;` нужен явно, иначе методы-расширения вроде `ReadOnlySpan.SequenceEqual` не разрешаются. + +**Замеры, ради которых всё делается** (реальный `account_tx`, 36 691 B на проводе): + +| Представление | Байт на ответ | +|---|---| +| `JsonElement` — сейчас | 65 369 | +| кадр целиком (уже выделен сокетом) | 36 729 | + +`JsonDocument.ParseValue` арендует 65 536 B и не возвращает в пул. После этой задачи промежуточного документа нет вовсе, а парс `result` выполняется один раз вместо двух. + +--- + +## File Structure + +**Создаются:** + +- `Xrpl/Client/Json/JsonSlice.cs` — `readonly struct JsonSlice { int Offset; int Length; }`. Границы токена внутри буфера. Ничего не знает о буфере. +- `Xrpl/Client/Json/Converters/JsonSliceConverter.cs` — снимает границы через `Utf8JsonReader`, не материализуя поддерево. `Write` запрещён. +- `Xrpl/Client/Json/RawJson.cs` — `readonly struct RawJson` над `(byte[] frame, int offset, int length)`. Публичный тип, через который уровень 1 будет отдавать исходный JSON. +- `Tests/Xrpl.Tests/Client/Json/Converters/JsonSliceConverterTests.cs` — тесты конвертера и `JsonSlice`, класс `TestUJsonSliceConverter`, namespace `XrplTests.Client.Json.Converters`. **Расположение обязательно**: в репозитории 23 файла тестов конвертеров лежат именно там и следуют этому шаблону имён. +- `Tests/Xrpl.Tests/Client/Json/RawJsonTests.cs` — тесты `RawJson`, класс `TestURawJson`, namespace `XrplTests.Client.Json`. Путь теста зеркалит путь исходника — так устроены 177 файлов из 255. +- Тесты `BaseResponse.RawResult`/`Frame` **отдельного файла не получают**: они идут в существующий `Tests/Xrpl.Tests/Client/TestUResponseParsing.cs`, где уже живут тесты разбора ответа и есть хелперы `Pending` и `BuildLedgerDataMessage`. + +**Изменяются:** + +- `Xrpl/Models/Subscriptions/BaseResponse.cs:38` — `object Result` → `JsonSlice ResultSlice` + `internal byte[]? Frame` + вычисляемое `RawJson RawResult`. +- `Xrpl/Client/RequestManager.cs:55-141, 500-517` — `HandleResponse` принимает кадр; `DeserializeResult` работает от `RawJson`. +- `Xrpl/Client/connection.cs:3223-3226` — передаёт `byte[]`, а не span. +- `Xrpl/Utils/Index.cs:60-63` — `HasNextPage` перестаёт быть всегда-`false`. +- `Tests/Xrpl.Tests/Client/TestUResponseParsing.cs` — подгонка под новые сигнатуры. + +**Breaking — удаляется поимённо, без переходных мостиков:** + +| Член | Судьба | +|---|---| +| `BaseResponse.Result` (`object`) | **удалить.** Замена: `ResultSlice` (границы) и `RawResult` (исходные байты) | +| `RequestManager.HandleResponse(ReadOnlySpan)` | **удалить.** Замена: `HandleResponse(byte[])` — span нельзя сохранить в поле, а кадр нужен на весь срок жизни ответа | +| `RequestManager.ParseEmptyObject()` (private) | **удалить** вместе с полем `EmptyResult` типа `JsonElement` | + +Ни один из них не помечается `[Obsolete]` и не дублируется перегрузкой «как было»: политика мажора — всё, что уходит, уходит сразу (см. спеку, раздел «Политика разрыва»). В `Xrpl/` сейчас нет ни одного `[Obsolete]`, и этот план его не заводит. + +Использований `BaseResponse.Result` внутри библиотеки, кроме `Index.cs:62` (сломанного), нет — проверено `grep`. Конверт ответа нигде не сериализуется — проверено `grep`, поэтому `JsonSliceConverter.Write` может кидать. + +--- + +### Task 1: JsonSlice и конвертер границ + +**Files:** +- Create: `Xrpl/Client/Json/JsonSlice.cs` +- Create: `Xrpl/Client/Json/Converters/JsonSliceConverter.cs` +- Test: `Tests/Xrpl.Tests/Client/TestURawResponseSlice.cs` + +- [ ] **Step 1: Написать падающий тест на точность границ** + +Создать `Tests/Xrpl.Tests/Client/TestURawResponseSlice.cs`: + +```csharp +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Xrpl.Client.Json; +using Xrpl.Client.Json.Converters; + +namespace Xrpl.Tests.ClientLib +{ + /// + /// Pins that the response envelope records where result sits in the frame instead of + /// materializing it. The slice has to be byte-exact: everything downstream — the typed + /// deserialization and the raw JSON handed to consumers — is cut from it. + /// + [TestClass] + public class TestURawResponseSlice + { + private sealed class SliceProbe + { + [JsonPropertyName("result")] + [JsonConverter(typeof(JsonSliceConverter))] + public JsonSlice Result { get; set; } + } + + [TestMethod] + public void TestUSliceMatchesResultSubtreeExactly() + { + // Deliberately irregular whitespace: the slice must reproduce the bytes as sent, + // not a normalized rendering of them. + string message = "{\"id\":\"7\", \"status\":\"success\", \"result\": {\"a\" : 1,\"b\":[2, 3]} , \"warning\":\"load\"}"; + byte[] frame = Encoding.UTF8.GetBytes(message); + + SliceProbe probe = JsonSerializer.Deserialize(frame, new JsonSerializerOptions()); + + string expected = "{\"a\" : 1,\"b\":[2, 3]}"; + string actual = Encoding.UTF8.GetString(frame, probe.Result.Offset, probe.Result.Length); + Assert.AreEqual(expected, actual); + } + + [TestMethod] + public void TestUSliceIsEmptyWhenResultAbsent() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"id\":\"7\",\"status\":\"success\"}"); + + SliceProbe probe = JsonSerializer.Deserialize(frame, new JsonSerializerOptions()); + + Assert.IsTrue(probe.Result.IsEmpty); + } + + [TestMethod] + public void TestUSliceCoversExplicitNull() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"id\":\"7\",\"result\":null}"); + + SliceProbe probe = JsonSerializer.Deserialize(frame, new JsonSerializerOptions()); + + Assert.AreEqual("null", Encoding.UTF8.GetString(frame, probe.Result.Offset, probe.Result.Length)); + } + } +} +``` + +- [ ] **Step 2: Запустить тест, убедиться что он не компилируется** + +Run: +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestURawResponseSlice" +``` +Expected: ошибка компиляции — `JsonSlice` и `JsonSliceConverter` не существуют. + +- [ ] **Step 3: Создать JsonSlice** + +`Xrpl/Client/Json/JsonSlice.cs`: + +```csharp +namespace Xrpl.Client.Json +{ + /// + /// Where a JSON token sits inside the buffer it was read from, as a byte offset and length. + /// Carries no reference to the buffer: the envelope that owns the frame pairs the two. + /// + public readonly struct JsonSlice + { + /// Byte offset of the first character of the token within the buffer. + public int Offset { get; } + + /// Length of the token in bytes. + public int Length { get; } + + /// True when no token was recorded — the member was absent from the buffer. + public bool IsEmpty => Length == 0; + + public JsonSlice(int offset, int length) + { + Offset = offset; + Length = length; + } + } +} +``` + +- [ ] **Step 4: Создать JsonSliceConverter** + +`Xrpl/Client/Json/Converters/JsonSliceConverter.cs`: + +```csharp +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Xrpl.Client.Json.Converters +{ + /// + /// Records where a member sits in the frame instead of materializing it. + /// + /// + /// Deserializing result into made System.Text.Json build a + /// self-contained for it, and JsonDocument.ParseValue rents + /// the backing array from without ever returning it — + /// 65 536 bytes for a 36 691-byte response, held for a subtree that was then parsed a second + /// time to reach the requested type. Skipping the subtree and remembering its bounds costs + /// nothing and leaves the single parse to the caller, straight out of the frame. + /// + public sealed class JsonSliceConverter : JsonConverter + { + /// + public override JsonSlice Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + long start = reader.TokenStartIndex; + reader.Skip(); + long end = reader.BytesConsumed; + return new JsonSlice(checked((int)start), checked((int)(end - start))); + } + + /// + /// Always throws. A response envelope describes what a node sent; re-emitting it from the + /// parsed form would produce a plausible but different document, which is the failure mode + /// this type exists to remove. + /// + public override void Write(Utf8JsonWriter writer, JsonSlice value, JsonSerializerOptions options) + { + throw new NotSupportedException( + "A response envelope is not serializable: write the original bytes through RawJson instead."); + } + } +} +``` + +- [ ] **Step 5: Запустить тесты, убедиться что проходят** + +Run: +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestURawResponseSlice" +``` +Expected: PASS, 3 теста. + +Если `TestUSliceMatchesResultSubtreeExactly` падает с лишним хвостовым пробелом — значит `BytesConsumed` захватил разделитель; в этом случае обрезать хвостовые пробельные байты в `Read` перед возвратом. Проверить фактический вывод прежде чем менять код. + +- [ ] **Step 6: Коммит** + +```bash +git add Xrpl/Client/Json/JsonSlice.cs Xrpl/Client/Json/Converters/JsonSliceConverter.cs Tests/Xrpl.Tests/Client/TestURawResponseSlice.cs +git commit -m "feat(client): записывать границы result в кадре вместо материализации JsonElement" +``` + +--- + +### Task 2: RawJson — публичный доступ к срезу + +**Files:** +- Create: `Xrpl/Client/Json/RawJson.cs` +- Test: `Tests/Xrpl.Tests/Client/TestURawResponseSlice.cs` (дополняется) + +- [ ] **Step 1: Написать падающий тест** + +Добавить в `TestURawResponseSlice` (внутрь класса, после существующих методов): + +```csharp + [TestMethod] + public void TestURawJsonRendersTheOriginalBytes() + { + // `{"result": {"a" : 1} }` — the inner object starts at byte 11 and is 9 bytes long. + byte[] frame = Encoding.UTF8.GetBytes("{\"result\": {\"a\" : 1} }"); + RawJson raw = new RawJson(frame, 11, 9); + + Assert.AreEqual("{\"a\" : 1}", raw.ToString()); + Assert.AreEqual(9, raw.Length); + Assert.IsFalse(raw.IsEmpty); + } + + [TestMethod] + public void TestURawJsonDefaultIsEmpty() + { + RawJson raw = default; + + Assert.IsTrue(raw.IsEmpty); + Assert.AreEqual(string.Empty, raw.ToString()); + Assert.AreEqual(0, raw.Span.Length); + } +``` + +Дописать в шапку файла `using Xrpl.Client.Json;` — он уже добавлен в Task 1, проверить что он на месте. + +- [ ] **Step 2: Запустить, убедиться что не компилируется** + +Run: +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestURawJson" +``` +Expected: ошибка компиляции — `RawJson` не существует. + +- [ ] **Step 3: Создать RawJson** + +`Xrpl/Client/Json/RawJson.cs`: + +```csharp +using System; +using System.Text; +using System.Text.Json; + +namespace Xrpl.Client.Json +{ + /// + /// The bytes a node actually sent for one member of a response, as they arrived. + /// + /// + /// A window onto the frame rather than a copy of it: the frame is the exact-sized array the + /// receive loop already allocated, so holding this costs nothing beyond keeping that array + /// alive. UTF-16 is never stored — builds it on demand, which for a + /// large response is twice the byte length and worth paying only when something needs text. + /// + public readonly struct RawJson + { + private readonly byte[]? _frame; + private readonly int _offset; + private readonly int _length; + + public RawJson(byte[] frame, int offset, int length) + { + _frame = frame; + _offset = offset; + _length = length; + } + + /// True when nothing was captured. + public bool IsEmpty => _frame is null || _length == 0; + + /// Length of the captured JSON in bytes. + public int Length => _frame is null ? 0 : _length; + + /// The captured JSON, as UTF-8, without copying. + public ReadOnlySpan Span => _frame is null ? default : _frame.AsSpan(_offset, _length); + + /// Copies the captured JSON into a new array. + public byte[] ToArray() => _frame is null ? Array.Empty() : Span.ToArray(); + + /// Writes the captured JSON into verbatim. + public void WriteTo(Utf8JsonWriter writer) + { + if (writer is null) + { + throw new ArgumentNullException(nameof(writer)); + } + + if (_frame is null) + { + writer.WriteNullValue(); + return; + } + + writer.WriteRawValue(Span, skipInputValidation: true); + } + + /// Materializes the captured JSON as text. Allocates; call only when text is needed. + public override string ToString() + { + return _frame is null ? string.Empty : Encoding.UTF8.GetString(_frame, _offset, _length); + } + } +} +``` + +- [ ] **Step 4: Запустить тесты** + +Run: +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestURawResponseSlice" +``` +Expected: PASS, 5 тестов. + +- [ ] **Step 5: Коммит** + +```bash +git add Xrpl/Client/Json/RawJson.cs Tests/Xrpl.Tests/Client/TestURawResponseSlice.cs +git commit -m "feat(client): RawJson — окно на исходные байты ответа без копии" +``` + +--- + +> ## ⚠ Задачи 3-6 атомарны: один коммит на всю группу +> +> Удаление `BaseResponse.Result` (Task 3) немедленно ломает сборку `RequestManager.cs` +> и `Index.cs`, поэтому между Task 3 и концом Task 6 репозиторий **не собирается** и +> тесты **не запускаются**. Промежуточных коммитов в этой группе нет: каждый из них +> был бы заведомо красным. +> +> Порядок внутри группы: правки Task 3 → Task 4 → Task 5 → Task 6, затем один прогон +> и один коммит в Task 6 Step 5. Тесты, написанные в Step 1 каждой задачи группы, +> пишутся сразу, но запускаются все вместе в конце — «падение» на этом отрезке +> означает ошибку компиляции, а не красный тест, и это ожидаемо. +> +> Для исполнителя-подагента: задачи 3-6 выдаются **одним заданием**, а не четырьмя. + +### Task 3: BaseResponse хранит срез, а не JsonElement + +**Почему `Frame` обязан остаться `internal`.** Границы, снятые конвертером, верны только когда ридер покрывал весь документ одним непрерывным буфером. На пути `JsonSerializer.Deserialize(Stream)` System.Text.Json парсит порциями и отдаёт конвертеру ридер над своим внутренним буфером — числа выходят относительно него, без исключения (замерено: на payload ~40 КБ offset 40012 вместо 40019). + +Этот путь обезврежен конструкцией, а не проверкой: `Frame` помечен `internal` и `[JsonIgnore]`, поэтому внешний вызов `Deserialize(stream)` оставит его `null`, и `RawResult` вернёт пустое значение вместо мусора. Заполняет `Frame` только `RequestManager`, ровно там же, где разбирает кадр. Отсюда правило: **`Frame` не делать публичным и не заполнять нигде, кроме `HandleResponse`** — иначе защита исчезает. + +**Files:** +- Modify: `Xrpl/Models/Subscriptions/BaseResponse.cs:36-39` +- Test: `Tests/Xrpl.Tests/Client/TestURawResponseSlice.cs` (дополняется) + +- [ ] **Step 1: Написать падающий тест** + +Добавить в `TestURawResponseSlice`, и дописать `using Xrpl.Models.Subscriptions;` к списку using в начале файла: + +```csharp + [TestMethod] + public void TestUEnvelopeExposesRawResultBoundToTheFrame() + { + string message = "{\"id\":\"7\",\"status\":\"success\",\"result\":{\"marker\":\"AABB\",\"n\":1}}"; + byte[] frame = Encoding.UTF8.GetBytes(message); + + ErrorResponse envelope = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); + envelope.Frame = frame; + + Assert.AreEqual("{\"marker\":\"AABB\",\"n\":1}", envelope.RawResult.ToString()); + } +``` + +Дополнительно добавь тест инварианта из преамбулы задачи: + +```csharp + /// + /// Bounds are only meaningful for a reader that covered one contiguous buffer, which the + /// Stream overloads do not. That path is disarmed by construction rather than by a check: + /// Frame is internal, so it stays null there and the raw result comes back empty instead + /// of pointing at bytes that were never checked. + /// + [TestMethod] + public void TestUEnvelopeParsedFromAStreamExposesNoRawResult() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"id\":\"7\",\"result\":{\"a\":1}}"); + using MemoryStream stream = new MemoryStream(frame); + + ErrorResponse envelope = JsonSerializer.Deserialize(stream, XrplJsonOptions.Default); + + Assert.IsTrue(envelope.RawResult.IsEmpty); + } +``` + +Понадобится `using System.IO;`. + +`Frame` объявлен `internal`, и это работает из тестов: `Xrpl/Xrpl.csproj:25` уже содержит ``. Дополнительной настройки не требуется. + +Дописать в шапку файла `using Xrpl.Models.Subscriptions;`. + +- [ ] **Step 2: Запустить, убедиться что не компилируется** + +Run: +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestUEnvelopeExposesRawResult" +``` +Expected: ошибка компиляции — `Frame` и `RawResult` не существуют. + +- [ ] **Step 3: Заменить Result на срез** + +В `Xrpl/Models/Subscriptions/BaseResponse.cs` дописать в список using: + +```csharp +using Xrpl.Client.Json; +``` + +и заменить блок + +```csharp + /// + /// (WebSocket only) The value success indicates the request was successfully received and understood by the server.
+ /// Some client libraries omit this field on success. + ///
+ [JsonPropertyName("result")] + public object Result { get; set; } +``` + +на + +```csharp + /// + /// Where the result member sits inside . + /// + /// + /// Deliberately not the parsed result: binding it to made + /// System.Text.Json build a whose pooled backing + /// array is never returned, and the member was then parsed a second time to reach the + /// requested type. Recording bounds costs nothing and leaves exactly one parse, cut + /// straight from the frame. + /// + [JsonPropertyName("result")] + [JsonConverter(typeof(JsonSliceConverter))] + public JsonSlice ResultSlice { get; set; } + + /// + /// The frame this response was read from. Set by RequestManager right after parsing; + /// null on an envelope built by hand. + /// + [JsonIgnore] + internal byte[]? Frame { get; set; } + + /// + /// The result member exactly as the node sent it. + /// + [JsonIgnore] + public RawJson RawResult => + Frame is null || ResultSlice.IsEmpty + ? default + : new RawJson(Frame, ResultSlice.Offset, ResultSlice.Length); +``` + +Дописать в тот же using-блок `Xrpl.Client.Json.Converters` — атрибут ссылается на конвертер. + +- [ ] **Step 4: Убедиться, что сборка падает ровно там, где ожидается** + +Run: +```bash +dotnet build Xrpl/Xrpl.csproj -v q --nologo 2>&1 | grep -E " error " | sed -E 's/.*\([A-Za-z]+\.cs)\(([0-9]+).*/:/' | sort -u +``` +Expected: только `RequestManager.cs` и `Index.cs`. Любой третий файл в списке — незамеченное использование `BaseResponse.Result`; разобраться с ним, прежде чем идти дальше. + +**Не коммитить**: группа 3-6 атомарна, коммит один и делается в Task 6 Step 5. + +--- + +### Task 4: RequestManager — один парс из среза + +**Files:** +- Modify: `Xrpl/Client/RequestManager.cs:55-141`, `:500-517` +- Test: `Tests/Xrpl.Tests/Client/TestUResponseParsing.cs` (дополняется) + +Тесты этой задачи пишутся в `TestUResponseParsing.cs`, а не в новом файле: там уже есть приватные хелперы `Pending(manager)` (`:65`) и `BuildLedgerDataMessage(Guid, int)` (`:29`), и переиспользовать их надёжнее, чем повторять. `XrplGRequest` — **вложенный** тип, обращаться к нему как `RequestManager.XrplGRequest`. `CreateGRequest` читает у объекта запроса свойство `Id` через рефлексию, поэтому анонимный объект туда передавать нельзя — только реальный request-тип, как это делает `Pending`. + +- [ ] **Step 1: Написать падающий тест** + +Добавить в класс `TestUResponseParsing`: + +```csharp + /// + /// The result member is no longer parsed on the way in — the envelope only records where it + /// sits — so the typed deserialization now has to cut it straight out of the frame. + /// + [TestMethod] + public void TestUTypedResultDeserializesFromTheSlice() + { + RequestManager manager = new RequestManager(); + RequestManager.XrplGRequest pending = Pending(manager); + + manager.HandleResponse(Encoding.UTF8.GetBytes(BuildLedgerDataMessage(pending.Id, 3))); + + LOLedgerData result = (LOLedgerData)pending.Promise.GetAwaiter().GetResult(); + Assert.IsNotNull(result); + Assert.IsNotNull(result.Marker); + Assert.AreEqual("AABBCCDD", result.Marker.ToString()); + } + + /// + /// A response carrying the raw frame must expose the result member byte for byte. + /// + [TestMethod] + public void TestURawResultReproducesWhatTheNodeSent() + { + RequestManager manager = new RequestManager(); + RequestManager.XrplGRequest pending = Pending(manager); + + string message = BuildLedgerDataMessage(pending.Id, 2); + (BaseResponse response, bool handled) = manager.HandleResponse(Encoding.UTF8.GetBytes(message)); + + Assert.IsTrue(handled); + int start = message.IndexOf("\"result\":", StringComparison.Ordinal) + "\"result\":".Length; + string expected = message.Substring(start, message.Length - start - 1); + Assert.AreEqual(expected, response.RawResult.ToString()); + } +``` + +Дописать в шапку файла `using Xrpl.Models.Subscriptions;`. + +- [ ] **Step 2: Запустить, зафиксировать провал** + +Run: +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestUTypedResultDeserializesFromTheSlice" +``` +Expected: ошибка компиляции `RequestManager.cs` — `response.Result` больше не существует. + +- [ ] **Step 3: Переписать разбор результата** + +В `Xrpl/Client/RequestManager.cs` заменить блок `EmptyResult`/`ParseEmptyObject` (`:58-70`) на + +```csharp + /// + /// Stands in for a missing result, matching what deserializing the literal + /// "{}" used to produce. + /// + private static readonly byte[] EmptyResult = Encoding.UTF8.GetBytes("{}"); +``` + +и дописать `using System.Text;` в шапку файла. Удалить метод `ParseEmptyObject`. + +Заменить тело `DeserializeResult` (`:117-141`) на + +```csharp + /// + /// Converts the result member of a response into the type the request was created + /// with, parsing it straight out of the frame. + /// + /// + /// The member is not parsed before this point: the envelope only recorded where it sits. + /// That leaves exactly one parse of the response body, against the UTF-8 the node sent, + /// with no intermediate document and no pooled array left unreturned. + /// + private object DeserializeResult(RawJson raw, Type type) + { + ReadOnlySpan json = raw.IsEmpty ? EmptyResult : raw.Span; + + // An explicit `"result": null` used to arrive as a missing member and produce an empty + // object rather than null; keep that. + if (json.SequenceEqual("null"u8)) + { + json = EmptyResult; + } + + return JsonSerializer.Deserialize(json, type, serializerOptions); + } +``` + +В `Resolve` (`:91`) заменить + +```csharp + object deserialized = DeserializeResult(response.Result, taskInfo.Type); +``` + +на + +```csharp + object deserialized = DeserializeResult(response.RawResult, taskInfo.Type); +``` + +Дописать `using Xrpl.Client.Json;` в шапку файла. + +- [ ] **Step 4: Переписать точки входа HandleResponse** + +Заменить блок `:500-517` на + +```csharp + public (BaseResponse Response, bool Handled) HandleResponse(string message) + { + return HandleResponse(Encoding.UTF8.GetBytes(message)); + } + + /// + /// Handles a message still in its wire form. This is the socket path. + /// + /// + /// The frame is kept rather than sliced away: the envelope records where result sits + /// inside it, and both the typed deserialization and + /// are cut from those bounds. The array is the exact-sized one the receive loop already + /// allocated, so keeping it costs nothing over what was allocated anyway. + /// + public (BaseResponse Response, bool Handled) HandleResponse(byte[] frame) + { + ErrorResponse response = JsonSerializer.Deserialize(frame, serializerOptions); + response.Frame = frame; + return HandleResponse(response); + } +``` + +- [ ] **Step 5: Проверить, что `RequestManager.cs` из списка ошибок ушёл** + +Run: +```bash +dotnet build Xrpl/Xrpl.csproj -v q --nologo 2>&1 | grep -E " error " | sed -E 's/.*\([A-Za-z]+\.cs)\(([0-9]+).*/:/' | sort -u +``` +Expected: остался только `Index.cs` — он чинится в Task 6. + +**Не коммитить**: группа 3-6 атомарна. + +--- + +### Task 5: connection.cs передаёт кадр + +**Files:** +- Modify: `Xrpl/Client/connection.cs:3223-3226` + +- [ ] **Step 1: Проверить, что правка нужна** + +Run: +```bash +dotnet build Xrpl/Xrpl.csproj -v q --nologo 2>&1 | grep -E "error" | head +``` +Expected: либо чисто (неявное преобразование `byte[]` уже подходит), либо ошибка на `:3225` о несоответствии перегрузки. + +- [ ] **Step 2: Если сборка чиста — пропустить задачу** + +Вызов на `:3225` передаёт `utf8Message`, объявленный как `byte[]`. Прежняя перегрузка принимала `ReadOnlySpan` через неявное преобразование; новая принимает `byte[]` напрямую, поэтому вызов компилируется без изменений. Отметить шаг выполненным и перейти к Task 6. + +- [ ] **Step 3: Если сборка падает — снять неоднозначность** + +Заменить на `:3223-3226` + +```csharp + (data, handled) = utf8Message is null + ? requestManager.HandleResponse(message) + : requestManager.HandleResponse(utf8Message); +``` + +на + +```csharp + (data, handled) = utf8Message is null + ? requestManager.HandleResponse(message) + : requestManager.HandleResponse(frame: utf8Message); +``` + +- [ ] **Step 4: Проверить сборку** + +Run: +```bash +dotnet build Xrpl/Xrpl.csproj -v q --nologo 2>&1 | grep -cE " error " +``` +Expected: `0` + +**Не коммитить**: группа 3-6 атомарна. Если файл не менялся — это ожидаемо, `byte[]` подходит под новую перегрузку без правок. + +--- + +### Task 6: HasNextPage перестаёт быть всегда-false + +**Files:** +- Modify: `Xrpl/Utils/Index.cs:60-63` +- Modify: `Tests/Xrpl.Tests/Utils/HasNextPage.cs` — сейчас это пустая заглушка + +`HasNextPage` сравнивает `Result` с `Dictionary`, а там всегда лежал `JsonElement` — метод возвращает `false` на любом ответе, включая страничные. Это тот же корневой дефект, и здесь он чинится естественно. + +Почему это не было замечено: `Tests/Xrpl.Tests/Utils/HasNextPage.cs` — **пустой класс без `[TestClass]` и без единого теста**, заготовка под порт `hasNextPage.ts` из xrpl.js, которую не наполнили. Тесты пишутся туда, а класс переименовывается в `TestUHasNextPage`: его текущее полное имя `XrplTests.Xrpl.Utils.HasNextPage` не содержит `TestU`, поэтому под фильтром CI он не запустился бы даже с тестами внутри. + +- [ ] **Step 1: Написать падающий тест** + +Заменить содержимое `Tests/Xrpl.Tests/Utils/HasNextPage.cs` целиком: + +```csharp +// https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/test/utils/hasNextPage.ts + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System.Text; +using System.Text.Json; + +using Xrpl.Client.Json; +using Xrpl.Models.Subscriptions; +using Xrpl.Utils; + +namespace XrplTests.Xrpl.Utils +{ + /// + /// Port of xrpl.js `hasNextPage.ts`. The class name carries the TestU prefix because the CI + /// filter matches on the fully qualified name — as `HasNextPage` it would never have run. + /// + [TestClass] + public class TestUHasNextPage + { + private static BaseResponse Envelope(string result) + { + byte[] frame = Encoding.UTF8.GetBytes($"{{\"id\":\"7\",\"status\":\"success\",\"result\":{result}}}"); + ErrorResponse envelope = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); + envelope.Frame = frame; + return envelope; + } + + [TestMethod] + public void TestUMarkerPresentMeansMorePages() + { + Assert.IsTrue(Envelope("{\"marker\":\"AABB\",\"state\":[]}").HasNextPage()); + } + + [TestMethod] + public void TestUMarkerAbsentMeansLastPage() + { + Assert.IsFalse(Envelope("{\"state\":[]}").HasNextPage()); + } + + /// The marker need not be first, and skipping over earlier members must not eat it. + [TestMethod] + public void TestUMarkerFoundAfterNestedMembers() + { + Assert.IsTrue(Envelope( + "{\"state\":[{\"a\":{\"b\":[1,2]}}],\"ledger_index\":9,\"marker\":{\"ledger\":9,\"seq\":1}}") + .HasNextPage()); + } + + /// A `marker` nested inside another member is not the paging marker. + [TestMethod] + public void TestUNestedMarkerIsNotThePagingMarker() + { + Assert.IsFalse(Envelope("{\"state\":[{\"marker\":\"AABB\"}]}").HasNextPage()); + } + + [TestMethod] + public void TestUEnvelopeWithoutResultHasNoNextPage() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"id\":\"7\",\"status\":\"success\"}"); + ErrorResponse envelope = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); + envelope.Frame = frame; + + Assert.IsFalse(envelope.HasNextPage()); + } + + /// An envelope built by hand carries no frame, so there is nothing to read. + [TestMethod] + public void TestUEnvelopeWithoutFrameHasNoNextPage() + { + Assert.IsFalse(new ErrorResponse().HasNextPage()); + } + } +} +``` + +Тест `TestUNestedMarkerIsNotThePagingMarker` — причина, по которой в Step 3 стоит `reader.Read(); reader.Skip();` после каждого несовпавшего имени: без пропуска значения сканер нашёл бы вложенный `marker` и соврал. + +- [ ] **Step 2: Запустить, зафиксировать провал** + +Run: +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestUHasNextPage" +``` +Expected: FAIL — все три `Assert.IsTrue` не выполняются (метод возвращает `false` на любом входе), либо ошибка компиляции на `Result`. + +- [ ] **Step 3: Переписать HasNextPage** + +В `Xrpl/Utils/Index.cs` заменить + +```csharp + public static bool HasNextPage(this BaseResponse response) + { + return response.Result is Dictionary dict && dict.ContainsKey("marker"); + } +``` + +на + +```csharp + /// + /// True when the node reported a marker, meaning more pages follow. + /// + /// + /// Read off the raw result rather than a parsed projection: the previous form compared + /// against Dictionary<string, object>, which the member never was, so it + /// answered false for every response including paged ones. + /// + public static bool HasNextPage(this BaseResponse response) + { + if (response is null || response.RawResult.IsEmpty) + { + return false; + } + + Utf8JsonReader reader = new Utf8JsonReader(response.RawResult.Span); + + if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject) + { + return false; + } + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return false; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + if (reader.ValueTextEquals("marker"u8)) + { + return true; + } + + reader.Read(); + reader.Skip(); + } + + return false; + } +``` + +Дописать `using System.Text.Json;` в шапку `Index.cs`, если его там нет. + +- [ ] **Step 4: Запустить тест** + +Run: +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestUHasNextPage" +``` +Expected: PASS. + +- [ ] **Step 5: Коммит** + +Это единственный коммит группы 3-6 — сюда входят `BaseResponse`, `RequestManager`, `connection` (если менялся) и `Index`. + +Сначала полный прогон, потому что до этого момента тесты не запускались ни разу: + +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestU" +``` +Expected: 0 падений, не менее 970 пройденных (964 базовых + 6 из `TestUHasNextPage`). + +```bash +git add Xrpl/Models/Subscriptions/BaseResponse.cs Xrpl/Client/RequestManager.cs Xrpl/Client/connection.cs Xrpl/Utils/Index.cs Tests/Xrpl.Tests/Client/TestURawResponseSlice.cs Tests/Xrpl.Tests/Client/TestUResponseParsing.cs Tests/Xrpl.Tests/Utils/HasNextPage.cs +git commit -m "perf(client)!: разбирать result один раз из кадра, конверт хранит границы вместо JsonElement" +``` + +--- + +### Task 7: Зафиксировать выигрыш и отсутствие регресса + +**Files:** +- Modify: `Tests/Xrpl.Tests/Client/TestUResponseParsing.cs` +- Test: тот же файл + +- [ ] **Step 1: Прогнать весь unit-набор** + +Run: +```bash +dotnet test --verbosity normal --settings test.runsettings --filter "TestU" +``` +Expected: PASS целиком. **Базовая линия до начала работ — 964 пройденных, 0 падений** (снято 2026-08-17 на `net10.0`). После задач 1-6 число может только вырасти: ни один тест не удаляется, а `HasNextPage` добавляет шесть новых. Любое падение — регресс этой задачи, чинить до продолжения; не помечать шаг выполненным по частично зелёному прогону. + +- [ ] **Step 2: Добавить тест бюджета удержания** + +Добавить в `TestUResponseParsing` (внутрь класса): + +```csharp + /// + /// The envelope must not retain more than the frame it was read from. Before the result + /// member became a slice, System.Text.Json built a JsonElement for it whose pooled backing + /// array — 65 536 bytes for a 36 691-byte response — was never returned to the pool. + /// + [TestMethod] + public void TestUEnvelopeRetainsNoMoreThanTheFrame() + { + byte[] frame = Encoding.UTF8.GetBytes(BuildLedgerDataMessage(Guid.NewGuid(), 200)); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + long before = GC.GetTotalMemory(true); + + const int Count = 50; + List retained = new List(Count); + for (int i = 0; i < Count; i++) + { + ErrorResponse envelope = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); + retained.Add(envelope); + } + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + long after = GC.GetTotalMemory(true); + + long perResponse = (after - before) / Count; + GC.KeepAlive(retained); + + // The frame is shared, so an envelope on its own is a handful of fields — nowhere near + // the pooled document the old shape kept alive. + Assert.IsTrue( + perResponse < 1024, + $"envelope retained {perResponse} B on its own; a pooled result document is back"); + } +``` + +Дописать недостающие using (`System.Collections.Generic`, `Xrpl.Models.Subscriptions`, `Xrpl.Client.Json`) в шапку файла. + +- [ ] **Step 3: Запустить тест бюджета** + +Run: +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestUEnvelopeRetainsNoMoreThanTheFrame" +``` +Expected: PASS. Если падает — значит `Frame` где-то копируется вместо разделения ссылки; найти копию, а не поднимать порог. + +- [ ] **Step 4: Прогнать интеграционные тесты** + +Поднять стенд и прогнать полностью — конвейер разбора трогает каждый запрос: + +```bash +docker compose -f .ci-config/docker-compose.ci.yml up -d +``` + +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --verbosity normal --settings test.runsettings --filter "TestI" +``` +Expected: PASS. Затем: + +```bash +docker compose -f .ci-config/docker-compose.ci.yml down +``` + +- [ ] **Step 5: Обновить CHANGES.md** + +Добавить в начало `CHANGES.md` раздел с таблицей «было → стало» — она заменяет собой отсутствующий совместимостный слой и потому обязана быть полной: + +```markdown +### Breaking + +| Было | Стало | +|---|---| +| `BaseResponse.Result` (`object`, на деле `JsonElement`) | `BaseResponse.RawResult` (`RawJson` — байты как их прислал узел); `BaseResponse.ResultSlice` — границы внутри кадра | +| `RequestManager.HandleResponse(ReadOnlySpan)` | `RequestManager.HandleResponse(byte[])` | + +Переходных перегрузок и `[Obsolete]`-обёрток нет: удалённые члены удалены. + +### Fixed + +- `HasNextPage()` возвращал `false` на любом ответе, включая страничные: он сравнивал + `Result` с `Dictionary`, чем тот никогда не был. + +### Performance + +- `result` разбирается один раз вместо двух, прямо из кадра. Промежуточный + `JsonElement` больше не строится — `JsonDocument.ParseValue` арендовал под него + 65 536 B на ответ в 36 691 B и не возвращал аренду в пул. +``` + +- [ ] **Step 6: Коммит** + +```bash +git add Tests/Xrpl.Tests/Client/TestUResponseParsing.cs CHANGES.md +git commit -m "test(client): зафиксировать бюджет удержания конверта ответа" +``` + +--- + +### Task 8: Довести покрытие новых членов и ужесточить порог бюджета + +**Files:** +- Modify: `Tests/Xrpl.Tests/Client/TestURawResponseSlice.cs` +- Modify: `Tests/Xrpl.Tests/Client/TestUResponseParsing.cs:186-215` + +Задачи 1–7 покрывают основные пути. Здесь закрываются остатки: члены `RawJson`, запрет записи конверта, срез над не-объектными значениями, инвариант независимости кадров — и приводится в соответствие порог существующего теста бюджета, который после Уровня 0 станет заведомо слабым. + +- [ ] **Step 1: Дописать тесты на непокрытые члены** + +Добавить в класс `TestURawResponseSlice`: + +```csharp + /// An envelope built by hand has no frame, so there is nothing to hand out. + [TestMethod] + public void TestUEnvelopeWithoutFrameHasEmptyRawResult() + { + Assert.IsTrue(new ErrorResponse().RawResult.IsEmpty); + } + + /// rippled always sends an object, but the slice must not assume it. + [TestMethod] + public void TestUSliceCoversNonObjectResults() + { + byte[] array = Encoding.UTF8.GetBytes("{\"result\":[1, 2]}"); + byte[] text = Encoding.UTF8.GetBytes("{\"result\":\"done\"}"); + byte[] number = Encoding.UTF8.GetBytes("{\"result\":42}"); + + Assert.AreEqual("[1, 2]", Slice(array)); + Assert.AreEqual("\"done\"", Slice(text)); + Assert.AreEqual("42", Slice(number)); + + static string Slice(byte[] frame) + { + SliceProbe probe = JsonSerializer.Deserialize(frame, new JsonSerializerOptions()); + return Encoding.UTF8.GetString(frame, probe.Result.Offset, probe.Result.Length); + } + } + + /// + /// Each response must read from its own frame. The receive loop hands out a fresh + /// exact-sized array per message, and nothing downstream may collapse two of them. + /// + [TestMethod] + public void TestUEnvelopesDoNotShareAFrame() + { + byte[] first = Encoding.UTF8.GetBytes("{\"id\":\"1\",\"result\":{\"n\":1}}"); + byte[] second = Encoding.UTF8.GetBytes("{\"id\":\"2\",\"result\":{\"n\":2}}"); + + RequestManager manager = new RequestManager(); + (BaseResponse a, _) = manager.HandleResponse(first); + (BaseResponse b, _) = manager.HandleResponse(second); + + Assert.AreEqual("{\"n\":1}", a.RawResult.ToString()); + Assert.AreEqual("{\"n\":2}", b.RawResult.ToString()); + } +``` + +Дописать в шапку файла `using System;`, `using System.Buffers;`, `using Xrpl.Client;`, `using Xrpl.Models.Subscriptions;`. + +- [ ] **Step 2: Запустить дописанные тесты** + +Run: +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestURawResponseSlice" +``` +Expected: PASS. `TestUEnvelopesDoNotShareAFrame` проходит с `handled = false` (запросов с такими id нет) — проверяется только `RawResult`, поэтому второй элемент кортежа отбрасывается. + +- [ ] **Step 3: Измерить новый бюджет аллокаций** + +Существующий `TestResponseParsingStaysWithinItsAllocationBudget` держит порог `ratio < 4.0`, рассчитанный на прежний двойной разбор. После Уровня 0 он станет заведомо слабым и перестанет что-либо ловить. Снять фактическое значение: + +Run: +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestResponseParsingStaysWithinItsAllocationBudget" --logger "console;verbosity=detailed" +``` +Тест печатает строку вида `response 1,234,567 bytes, 1.23 MB allocated per response (1.70x)`. Записать фактический `ratio`. + +- [ ] **Step 4: Ужесточить порог** + +В `TestUResponseParsing.cs` заменить + +```csharp + Assert.IsTrue( + ratio < 4.0, +``` + +на порог, равный **измеренному в Step 3 значению плюс 0.5** — запас на дрожание GC между прогонами, но недостаточный, чтобы пропустить возврат второго разбора. Например, при измеренных `1.70x` записать `2.2`: + +```csharp + // Threshold tracks the single-parse path measured after the result member became a + // slice. The old bound of 4.0 was sized for the double round-trip and would no longer + // catch its return. + Assert.IsTrue( + ratio < 2.2, +``` + +Подставить своё измеренное число, а не 2.2, если оно отличается. Обновить и текст `` над тестом, чтобы он описывал текущий путь, а не прежний. + +- [ ] **Step 5: Перепроверить порог трижды подряд** + +Run: +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestResponseParsingStaysWithinItsAllocationBudget" +``` +Повторить три раза. Expected: PASS все три. Если хоть один прогон падает — порог занижен, поднять на 0.3 и перепроверить; не отключать тест. + +- [ ] **Step 6: Финальный полный прогон** + +Run: +```bash +dotnet test --verbosity normal --settings test.runsettings --filter "TestU" +``` +Expected: PASS целиком, 0 падений, и **не менее 964 + 22 = 986 пройденных**: 964 базовых (ни один не удалён) плюс 16 новых из `TestURawResponseSlice`, 6 из `TestUHasNextPage`. Два теста добавляются в `TestUResponseParsing`, поэтому фактическое число будет ещё выше. Если итог меньше 986 — тест потерян или не попал под фильтр; найти какой, прежде чем закрывать задачу. + +- [ ] **Step 7: Коммит** + +```bash +git add Tests/Xrpl.Tests/Client/TestURawResponseSlice.cs Tests/Xrpl.Tests/Client/TestUResponseParsing.cs +git commit -m "test(client): покрыть RawJson, срез над не-объектами и ужесточить бюджет аллокаций" +``` + +--- + +## Матрица покрытия + +Каждый новый и изменённый член — и тест, который его держит. Пустых клеток быть не должно; если при исполнении появится член, которого здесь нет, тест на него обязателен. + +| Член | Тест | +|---|---| +| `JsonSlice.Offset` / `.Length` | `TestUSliceMatchesResultSubtreeExactly` | +| `JsonSlice.IsEmpty` | `TestUSliceIsEmptyWhenResultAbsent` | +| `JsonSliceConverter.Read` — объект | `TestUSliceMatchesResultSubtreeExactly` | +| `JsonSliceConverter.Read` — `null` | `TestUSliceCoversExplicitNull` | +| `JsonSliceConverter.Read` — массив / строка / число | `TestUSliceCoversNonObjectResults` | +| `JsonSliceConverter.Read` — скобки внутри строкового значения | `TestUSliceSkipsBracesInsideStrings` (Task 1) | +| `JsonSliceConverter.Read` — смещения байтовые, не символьные | `TestUSliceOffsetsAreByteBased` (Task 1) | +| `JsonSliceConverter.Read` — под `XrplJsonOptions.Default` | `TestUSliceIsTheSameUnderProductionOptions` (Task 1) | +| `JsonSliceConverter.Read` — крупный кадр (защита от chunked-регресса) | `TestUSliceStaysExactOnALargeFrame` (Task 1) | +| `JsonSliceConverter.Read` — член отсутствует | `TestUSliceIsEmptyWhenResultAbsent` | +| `JsonSliceConverter.Write` кидает | `TestUWritingASliceIsRejected` (Task 1) | +| `RawJson.ToString` / `.Length` / `.IsEmpty` | `TestURawJsonRendersTheOriginalBytes` (Task 2) | +| `RawJson` — `default` | `TestURawJsonDefaultIsEmpty` (Task 2) | +| `RawJson.Span` алиасит кадр, не копирует | `TestURawJsonSpanAliasesTheFrame` (Task 2) | +| `RawJson.ToArray` отвязывает от кадра | `TestURawJsonToArrayDetachesFromTheFrame` (Task 2) | +| `RawJson` — окно нулевой длины над живым кадром | `TestURawJsonZeroLengthWindowIsEmpty` (Task 2) | +| `RawJson.WriteTo` — со срезом | `TestURawJsonWriteToEmitsTheBytesVerbatim` (Task 2) | +| `RawJson.WriteTo` — пустое окно и `default` | `TestURawJsonWriteToEmitsNullForAnEmptyWindow` (Task 2) | +| `RawJson.WriteTo` — null-writer | `TestURawJsonWriteToRejectsANullWriter` (Task 2) | +| `RawJson` — окно вне кадра, отрицательные значения | `TestURawJsonRejectsAWindowOutsideTheFrame` (Task 2) | +| `RawJson.Length` в байтах, не символах | `TestURawJsonLengthIsInBytes` (Task 2) | +| `RawJson` — равенство по идентичности окна | покрыто конструкцией `IEquatable`; отдельный тест не требуется | +| `BaseResponse.ResultSlice` | `TestUEnvelopeExposesRawResultBoundToTheFrame` | +| `BaseResponse.Frame` | `TestUEnvelopesDoNotShareAFrame` | +| `BaseResponse.RawResult` | `TestURawResultReproducesWhatTheNodeSent` | +| `BaseResponse.RawResult` без кадра | `TestUEnvelopeWithoutFrameHasEmptyRawResult` | +| `BaseResponse.RawResult` при разборе из `Stream` | `TestUEnvelopeParsedFromAStreamExposesNoRawResult` | +| `HandleResponse(byte[])` | `TestUTypedResultDeserializesFromTheSlice` | +| `HandleResponse(string)` | `TestUtf8AndStringOverloadsProduceTheSameResult` (существующий) | +| `DeserializeResult` — типизированный | `TestTypedRequestDeserializesFromTheParsedResultNode` (существующий) | +| `DeserializeResult` — `JsonElement` / `object` | `TestUntypedRequestGetsTheParsedResultNode` (существующий) | +| `DeserializeResult` — `result: null` | `TestResponseWithoutResultStillCompletes` (существующий) | +| `DeserializeResult` — `result` отсутствует | `TestResponseWithoutResultStillCompletes` (существующий) | +| ветка `status: "error"` | `TestErrorStatusRejectsWithTheParsedErrorResponse` (существующий) | +| `HasNextPage` — marker есть / нет | `TestUMarkerPresentMeansMorePages`, `TestUMarkerAbsentMeansLastPage` | +| `HasNextPage` — marker после вложенных членов | `TestUMarkerFoundAfterNestedMembers` | +| `HasNextPage` — вложенный marker не считается | `TestUNestedMarkerIsNotThePagingMarker` | +| `HasNextPage` — нет `result` / нет кадра | `TestUEnvelopeWithoutResultHasNoNextPage`, `TestUEnvelopeWithoutFrameHasNoNextPage` | +| `HasNextPage` — `result` не объект | `TestUNonObjectResultHasNoNextPage` | +| `HasNextPage` — пустой объект | `TestUEmptyResultObjectHasNoNextPage` | +| `HasNextPage` — экранированный ключ `marker` | `TestUEscapedMarkerKeyIsRecognized` | +| `HasNextPage` — ключи-почтисовпадения | `TestUNearMissKeysAreNotTheMarker` | +| владение кадром: ответ алиасит переданный массив | `TestUResponseAliasesTheFrameItWasGiven` | +| бюджет удержания конверта | `TestUEnvelopeRetainsNoMoreThanTheFrame` | +| бюджет аллокаций разбора | `TestResponseParsingStaysWithinItsAllocationBudget` (существующий, порог ужесточается) | + +**Существующие тесты, которые обязаны остаться зелёными без правки логики** — проверено прогоном на реальном поведении System.Text.Json: + +| Тест | Почему переживёт замену | +|---|---| +| `TestUntypedRequestGetsTheParsedResultNode` | `Deserialize(span, typeof(JsonElement))` даёт самодостаточный `JsonElement`, переживающий gen2 GC | +| `TestResponseWithoutResultStillCompletes` | `"{}"` даёт `ValueKind.Object` без `state`; типизированный путь даёт объект с `State == null` | +| `TestUtf8AndStringOverloadsProduceTheSameResult` | строковая перегрузка кодирует в UTF-8 и идёт тем же путём | +| `TestErrorStatusRejectsWithTheParsedErrorResponse` | ветка ошибки читает поля конверта, а не `result` | +| `TestCancellationToken` (`:66`, `:87`) | вызывает `HandleResponse(string)`, сигнатура сохранена | + +`typeof(object)` отдельно проверен: `DictionaryObjectConverter` объявлен как `JsonConverter>` и на `object` не распространяется, поэтому результат остаётся `JsonElement`, как и раньше. + +--- + +## Найдено по ходу: `object` остался ещё в двух полях конверта + +Ревью группы 3-6 замерило то, что этот план не покрывает. `result` переведён на срез, но в том же классе `JsonElement` живёт дальше: + +| Поле | Цена сверх парса | Когда | +|---|---|---| +| `BaseResponse.Id` (`object?`) | ~248 B | **каждый** ответ | +| `ErrorResponse.Request` (`object`) | ~360 B | каждый ответ-ошибка | + +Это та же невозвращаемая аренда `ArrayPool` из `JsonDocument.ParseValue`, только мелкими порциями. `Id` вдобавок форматируется через `Guid.TryParse($"{response.Id}")` — ещё строка на каждый ответ. + +Отдельно неприятен `Request`: `Sugar/Submit.cs:425` ловит `RippledException` с `txnNotFound` в **цикле опроса** `SubmitAndWait`, то есть ~360 байт невозвращаемой аренды на каждый опрос неподтверждённой транзакции. + +Заявка на отдельную задачу: `Id` привести к строгому типу (это либо строка, либо число — `Guid` в нашем случае), `Request` перевести на `JsonSlice` тем же конвертером. До тех пор формулировка «конверт хранит границы вместо JsonElement» верна лишь наполовину. + +## Перенесено в уровень 1 (из финального ревью) + +Ничего из этого не блокирует уровень 0, но должно быть сделано до или вместе с `XrplResponse`: + +1. **`AttachFrame(byte[])` вместо сеттера `Frame`.** Сейчас парность кадра и границ держится дисциплиной («сеттер зовут сразу после `Deserialize`, тем же массивом»), а проверка границ прогоняется на каждом обращении к `RawResult`. Метод, сверяющий `ResultSlice` с `frame.Length` один раз, делает инвариант структурным и `RawResult` бесплатным. +2. **Скан верхнего уровня вынести из `Utils/Index.cs` на `RawJson`** (`HasTopLevelProperty` / `TryGetTopLevelProperty`). `HasNextPage` — первый потребитель, уровень 1 будет вторым, внешние потребители третьим. Иначе цикл `Utf8JsonReader` перепишут трижды. +3. **`RawJson.Deserialize()` / `ToJsonElement()`** — иначе каждый потребитель напишет `JsonSerializer.Deserialize(raw.Span, ...)` со своими опциями, мимо `XrplJsonOptions.Default`. +4. **Закрепить тестом сокетное число.** Главное достижение уровня 0 — 92 736 → 2 432 B на сообщение — не защищено ничем: это разовый замер в тексте. Нужен бюджет как отношение к длине сообщения, по образцу `TestTypedResponseParsingStaysWithinItsAllocationBudget`. Инфраструктура есть — `PagedResponseServer` и существующий тест через реальный сокет. +5. **Решить политику удержания кадра для `XrplResponse`.** Каждый удержанный ответ пиннит весь кадр. Для `account_tx` кадр и есть `result`, но для постраничного обхода политику надо назвать явно — пиннить или `ToArray()`. +6. **`RawJson.WriteTo` на пустом окне пишет `null`.** Для API «воспроизвести, что прислал узел» отсутствующий член и `null` — не одно и то же; уровень 1 не должен звать это вслепую. + +## Что этот план сознательно не делает + +- Не добавляет `XrplResponse` и не меняет сигнатуры 40 методов клиента — это уровень 1, отдельный план. Здесь только создаётся `RawJson`, на котором тот уровень будет построен. +- Не трогает nullability моделей и `[JsonExtensionData]` — уровень 2. +- Не разводит v1/v2 (`Amount`/`DeliverMax`, `tx`/`tx_json`, `meta`/`meta_blob`) — уровень 3. +- Не добавляет CI-проверку fidelity — уровень 4. +- Оставляет `ErrorResponse.Request` типом `object`: он заполняется только на ветке ошибок и там же и остаётся, поэтому аренда на нём редка. Перевести на срез можно позже, если это всплывёт в замерах. diff --git a/plans/2026-08-17-raw-response-level1.md b/plans/2026-08-17-raw-response-level1.md new file mode 100644 index 00000000..46395fc9 --- /dev/null +++ b/plans/2026-08-17-raw-response-level1.md @@ -0,0 +1,836 @@ +# Raw Response, уровень 1: `XrplResponse` — конверт ответа как тип + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Отдать потребителю байт-точный JSON узла рядом с типизированной моделью и вернуть потерянный конверт ответа (`warnings`, `api_version`, `forwarded`), заменив возвращаемый тип 43 методов клиента на `XrplResponse`. + +**Architecture:** Уровень 0 уже сохранил границы `result` внутри кадра и отдаёт их через `BaseResponse.RawResult`. Здесь эта величина доводится до вызывающего: `RequestManager.Resolve` кладёт в промис не голый типизированный объект, а пару «типизированное + конверт», а `XrplClient.GRequest` собирает из неё `XrplResponse`. Все 43 типизированных метода делегируют в эту единственную точку, поэтому правится одна реализация и 43 сигнатуры. + +**Tech Stack:** .NET 8/9/10, System.Text.Json, MSTest 4.0.2 (`Assert.ThrowsExactly`, не `ThrowsException`; `ImplicitUsings` выключен). + +**Базовая линия перед началом:** 1001 unit-тест и 265 интеграционных, 0 падений. + +--- + +## Решения, принятые до плана + +**Неявных конверсий нет.** `XrplResponse` не получает `implicit operator T`. Замерено: результат принимают через `var` в 273 местах и с явным типом в 248 — конверсия спасла бы меньше половины, оставив «совместимость через раз», которую мигрировать труднее, чем честный слом. Явное `.Result` вдобавок показывает в коде, что рядом есть ещё что-то. + +**Переходных мостиков нет** (политика мажора, см. спеку): старые сигнатуры не сохраняются ни `[Obsolete]`-обёртками, ни перегрузками. + +**Объём слома (замерено после Task 2-4, а не оценено):** 214 ошибок компиляции в тестах плюс 86 в единственном моке `IXrplClient`. Разбивка: 166 CS0029 (`XrplResponse` не приводится к `T`), 34 CS1061 (места с `var`), 8 CS0023 (`?.` на `readonly struct` — обёртка не может быть null), 6 приведений. + +**Ловушка при подсчёте:** пока `FeeTestClient` в `Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs` не реализует интерфейс, его 86 CS0738 **маскируют все остальные ошибки** — сборка показывает 86 и молчит про 214. Чинить мок надо первым, иначе объём работы не виден. Числа выше сняты временным исключением этого файла из компиляции. + +--- + +## File Structure + +**Создаются:** + +- `Xrpl/Client/XrplResponse.cs` — `readonly struct XrplResponse`: `Result`, `Raw`, `ApiVersion`, `Warnings`, `Forwarded`. +- `Xrpl/Client/ResolvedResponse.cs` — `public sealed class ResolvedResponse`: пара «типизированный результат + конверт», единственное, что кладётся в промис. Публичный, потому что `RequestManager` и его `Promise` (`Task`) публичны — вызывающему на этом уровне нужно чем распаковать результат. +- `Tests/Xrpl.Tests/Client/XrplResponseTests.cs` — класс `TestUXrplResponse`, namespace `XrplTests.Client`. + +**Изменяются:** + +- `Xrpl/Client/Json/RawJson.cs` — добавляются `Deserialize()`, `ToJsonElement()`, `HasTopLevelProperty()`. +- `Xrpl/Models/Subscriptions/BaseResponse.cs` — сеттер `Frame` заменяется на `AttachFrame(byte[])`. +- `Xrpl/Client/RequestManager.cs:85, 301, 398` — в промис кладётся `ResolvedResponse`. +- `Xrpl/Client/IXrplClient.cs` — 43 сигнатуры в интерфейсе и 43 в реализации, плюс `GRequest` и `Request`. +- `Xrpl/Utils/Index.cs` — `HasNextPage` переходит на `RawJson.HasTopLevelProperty`. +- `Xrpl/Sugar/*.cs`, `Xrpl/Wallet/*.cs` — 10 файлов, 18 вызовов. +- Тесты — около 520 мест. +- `CHANGES.md`. + +**Breaking — удаляется поимённо, без мостиков:** + +| Член | Судьба | +|---|---| +| `Task IXrplClient.<43 метода>(...)` | **сигнатура меняется** на `Task>` | +| `Task IXrplClient.GRequest(...)` | **меняется** на `Task>` | +| `Task> IXrplClient.Request(...)` | **меняется** на `Task>>` | +| `BaseResponse.Frame` (сеттер) | **удалить**, заменить методом `AttachFrame(byte[])` | + +--- + +### Task 1: Инфраструктура на `RawJson` и `AttachFrame` + +Переносы 1–3 из финального ревью уровня 0. Делается первым: на этом стоит всё остальное. + +**Files:** +- Modify: `Xrpl/Client/Json/RawJson.cs` +- Modify: `Xrpl/Models/Subscriptions/BaseResponse.cs` +- Modify: `Xrpl/Client/RequestManager.cs` (вызов `AttachFrame`) +- Modify: `Xrpl/Utils/Index.cs` (`HasNextPage` на общий скан) +- Test: `Tests/Xrpl.Tests/Client/Json/RawJsonTests.cs` + +- [ ] **Step 1: Написать падающие тесты** + +Добавить в `TestURawJson` (`Tests/Xrpl.Tests/Client/Json/RawJsonTests.cs`): + +```csharp + [TestMethod] + public void TestURawJsonDeserializesWithLibraryOptions() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"result\":{\"ledger_index\":9,\"marker\":\"AABB\"}}"); + RawJson raw = new RawJson(frame, 10, frame.Length - 11); + + LOLedgerData typed = raw.Deserialize(); + + Assert.IsNotNull(typed); + Assert.AreEqual("AABB", typed.Marker.ToString()); + } + + [TestMethod] + public void TestURawJsonDeserializeOnAnEmptyWindowReturnsDefault() + { + Assert.IsNull(default(RawJson).Deserialize()); + } + + [TestMethod] + public void TestURawJsonToJsonElementOwnsItsData() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"result\":{\"a\":1}}"); + JsonElement element = new RawJson(frame, 10, 7).ToJsonElement(); + + frame[11] = (byte)'z'; + GC.Collect(2, GCCollectionMode.Forced, blocking: true); + + // The element is self-contained: it copied out of the frame rather than aliasing it. + Assert.AreEqual(1, element.GetProperty("a").GetInt32()); + } + + [TestMethod] + public void TestURawJsonToJsonElementOnAnEmptyWindowIsUndefined() + { + Assert.AreEqual(JsonValueKind.Undefined, default(RawJson).ToJsonElement().ValueKind); + } + + [TestMethod] + public void TestURawJsonFindsTopLevelPropertiesOnly() + { + Assert.IsTrue(Window("{\"marker\":1,\"a\":2}").HasTopLevelProperty("marker"u8)); + Assert.IsTrue(Window("{\"a\":{\"b\":[1,2]},\"marker\":1}").HasTopLevelProperty("marker"u8)); + Assert.IsFalse(Window("{\"a\":[{\"marker\":1}]}").HasTopLevelProperty("marker"u8)); + Assert.IsFalse(Window("{}").HasTopLevelProperty("marker"u8)); + Assert.IsFalse(Window("[1,2]").HasTopLevelProperty("marker"u8)); + Assert.IsFalse(default(RawJson).HasTopLevelProperty("marker"u8)); + Assert.IsTrue(Window("{\"\\u006darker\":1}").HasTopLevelProperty("marker"u8)); + + static RawJson Window(string json) + { + byte[] frame = Encoding.UTF8.GetBytes(json); + return new RawJson(frame, 0, frame.Length); + } + } +``` + +Дописать в шапку файла `using Xrpl.Models.Ledger;`. + +И в `Tests/Xrpl.Tests/Client/TestUResponseParsing.cs`: + +```csharp + /// + /// Pairing is done in one call that checks the bounds against the frame, so a frame that + /// does not match the recorded slice is rejected where the two meet rather than lazily, + /// inside a consumer's read. + /// + [TestMethod] + public void TestUAttachFrameRejectsAFrameThatDoesNotFitTheSlice() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"id\":\"7\",\"result\":{\"a\":1}}"); + ErrorResponse envelope = JsonSerializer.Deserialize(frame, XrplJsonOptions.Default); + + Assert.ThrowsExactly(() => envelope.AttachFrame(Encoding.UTF8.GetBytes("{}"))); + } +``` + +- [ ] **Step 2: Запустить, зафиксировать провал** + +Run: +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestURawJson|TestUAttachFrame" +``` +Expected: ошибка компиляции — `Deserialize`, `ToJsonElement`, `HasTopLevelProperty`, `AttachFrame` не существуют. + +- [ ] **Step 3: Добавить члены в `RawJson`** + +В `Xrpl/Client/Json/RawJson.cs`, после `ToArray()`: + +```csharp + /// + /// Deserializes the captured JSON into using the library's + /// serializer options. + /// + /// + /// Here so that a consumer does not reach for JsonSerializer.Deserialize with + /// options of their own: the XRPL models depend on the converters in + /// , and bare options silently produce a different + /// object. Returns default for an empty window rather than throwing — an absent + /// member is not a malformed one. + /// + public T Deserialize() + { + return IsEmpty ? default : JsonSerializer.Deserialize(Span, XrplJsonOptions.Default); + } + + /// + /// Parses the captured JSON into a self-contained . + /// + /// + /// The element copies out of the frame, so it stays readable after the frame is gone — + /// unlike , which aliases it. An empty window yields + /// . + /// + public JsonElement ToJsonElement() + { + if (IsEmpty) + { + return default; + } + + using (JsonDocument document = JsonDocument.Parse(ToArray())) + { + return document.RootElement.Clone(); + } + } + + /// + /// True when the captured JSON is an object carrying at its top + /// level. + /// + /// + /// Each non-matching member's value is skipped whole, so a nested occurrence of the name + /// cannot be mistaken for a top-level one. Matching goes through + /// , which unescapes — a + /// raw byte comparison would miss \u006darker. + /// + public bool HasTopLevelProperty(ReadOnlySpan name) + { + if (IsEmpty) + { + return false; + } + + Utf8JsonReader reader = new Utf8JsonReader(Span); + + if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject) + { + return false; + } + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return false; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + if (reader.ValueTextEquals(name)) + { + return true; + } + + reader.Skip(); + } + + return false; + } +``` + +Дописать `using Xrpl.Client.Json;` не требуется — файл уже в этом namespace. Проверить, что `XrplJsonOptions` виден (тот же namespace). + +- [ ] **Step 4: Заменить сеттер `Frame` на `AttachFrame`** + +В `Xrpl/Models/Subscriptions/BaseResponse.cs` заменить + +```csharp + [JsonIgnore] + internal byte[]? Frame { get; set; } +``` + +на + +```csharp + [JsonIgnore] + private byte[]? _frame; + + /// + /// Pairs this envelope with the frame it was read from. + /// + /// + /// One call instead of a settable property, so the bounds are checked against the buffer + /// once, where the two meet — a frame that does not match the recorded slice is rejected + /// here rather than lazily, inside a consumer's read of . Internal + /// on purpose: the bounds are only meaningful for a reader that covered one contiguous + /// buffer, which the Stream overloads of System.Text.Json do not, and keeping this + /// unreachable disarms that path by construction. + /// + /// + /// is too short for the recorded slice. + /// + internal void AttachFrame(byte[] frame) + { + if (frame is null) + { + throw new ArgumentNullException(nameof(frame)); + } + + if (!ResultSlice.IsEmpty + && (ResultSlice.Offset > frame.Length || ResultSlice.Length > frame.Length - ResultSlice.Offset)) + { + throw new ArgumentException( + $"Frame of {frame.Length} bytes does not contain the recorded result at " + + $"[{ResultSlice.Offset}, {ResultSlice.Offset + (long)ResultSlice.Length}).", + nameof(frame)); + } + + _frame = frame; + } +``` + +и заменить `RawResult` на: + +```csharp + [JsonIgnore] + public RawJson RawResult => + _frame is null || ResultSlice.IsEmpty + ? default + : new RawJson(_frame, ResultSlice.Offset, ResultSlice.Length); +``` + +Дописать `using System;` в шапку, если его нет. + +- [ ] **Step 5: Обновить вызов в `RequestManager`** + +В `HandleResponse(byte[] frame)` заменить `response.Frame = frame;` на `response.AttachFrame(frame);`. + +- [ ] **Step 6: Перевести `HasNextPage` на общий скан** + +В `Xrpl/Utils/Index.cs` заменить тело метода (весь ручной цикл `Utf8JsonReader`) на: + +```csharp + public static bool HasNextPage(this BaseResponse response) + { + return response is not null && response.RawResult.HasTopLevelProperty("marker"u8); + } +``` + +XML-doc над методом оставить. Убрать `using System.Text.Json;`, если после правки он больше не используется — **проверить**, `JsonNode Decode` в этом файле может требовать `System.Text.Json.Nodes`, но не `System.Text.Json`. + +- [ ] **Step 7: Прогон** + +Run: +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestU" +``` +Expected: **1007 пройдено** (1001 + 5 в `TestURawJson` + 1 в `TestUResponseParsing`), 0 падений. Все десять тестов `TestUHasNextPage` обязаны остаться зелёными без правок — они и проверяют, что перенос скана ничего не изменил. + +- [ ] **Step 8: Коммит** + +```bash +git add Xrpl/Client/Json/RawJson.cs Xrpl/Models/Subscriptions/BaseResponse.cs Xrpl/Client/RequestManager.cs Xrpl/Utils/Index.cs Tests/Xrpl.Tests/Client/Json/RawJsonTests.cs Tests/Xrpl.Tests/Client/TestUResponseParsing.cs +git commit -m "feat(client): RawJson умеет разбор и скан верхнего уровня; кадр привязывается одним AttachFrame" +``` + +--- + +### Task 2: `XrplResponse` и доставка конверта до вызывающего + +**Files:** +- Create: `Xrpl/Client/XrplResponse.cs`, `Xrpl/Client/ResolvedResponse.cs` +- Modify: `Xrpl/Client/RequestManager.cs:85, 301, 398` +- Modify: `Xrpl/Client/IXrplClient.cs` (только `GRequest` и `Request`) +- Test: `Tests/Xrpl.Tests/Client/XrplResponseTests.cs` + +- [ ] **Step 1: Написать падающий тест** + +Создать `Tests/Xrpl.Tests/Client/XrplResponseTests.cs`: + +```csharp +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Text; + +using Xrpl.Client; +using Xrpl.Client.Json; +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; + +namespace XrplTests.Client; + +/// +/// The envelope a caller gets back: the typed projection and, beside it, the bytes the node sent. +/// The point of the pair is that the projection cannot be mistaken for the source — re-serializing +/// it drops members the model lacks and invents defaults for non-nullable CLR properties. +/// +[TestClass] +public class TestUXrplResponse +{ + [TestMethod] + public void TestUCarriesResultAndRawSideBySide() + { + byte[] frame = Encoding.UTF8.GetBytes("{\"result\":{\"ledger_index\":9,\"marker\":\"AABB\"}}"); + RawJson raw = new RawJson(frame, 10, frame.Length - 11); + LOLedgerData typed = raw.Deserialize(); + + XrplResponse response = new XrplResponse(typed, raw, 2, null, false); + + Assert.AreSame(typed, response.Result); + Assert.AreEqual("{\"ledger_index\":9,\"marker\":\"AABB\"}", response.Raw.ToString()); + Assert.AreEqual(2u, response.ApiVersion); + } + + [TestMethod] + public void TestUWarningsAreNeverNull() + { + XrplResponse response = new XrplResponse(null, default, null, null, false); + + Assert.IsNotNull(response.Warnings); + Assert.AreEqual(0, response.Warnings.Count); + } +} +``` + +- [ ] **Step 2: Запустить, зафиксировать провал** + +Run: +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestUXrplResponse" +``` +Expected: ошибка компиляции — `XrplResponse` не существует. + +- [ ] **Step 3: Создать `XrplResponse`** + +`Xrpl/Client/XrplResponse.cs`: + +```csharp +using System; +using System.Collections.Generic; + +using Xrpl.Client.Json; +using Xrpl.Models.Subscriptions; + +namespace Xrpl.Client +{ + /// + /// A response from a node: the typed projection of its result, and the bytes that + /// projection was made from. + /// + /// + /// The pair exists because the projection is lossy in both directions and cannot be turned + /// back into what arrived: members the model does not know are dropped, and non-nullable CLR + /// properties re-serialize as zeros that the node never sent. Anything that has to show or + /// verify what a node actually said — a wallet rendering a transaction for signing — reads + /// ; everything else reads . + /// + /// There is deliberately no implicit conversion to . It would carry + /// less than half the call sites (those with an explicit type; the ones using var break + /// regardless), leaving a partial compatibility that is harder to migrate than a clean break, + /// and it would hide that exists at all. + /// + /// + public readonly struct XrplResponse + { + private readonly IReadOnlyList _warnings; + + public XrplResponse( + T result, + RawJson raw, + uint? apiVersion, + IReadOnlyList warnings, + bool forwarded) + { + Result = result; + Raw = raw; + ApiVersion = apiVersion; + _warnings = warnings; + Forwarded = forwarded; + } + + /// The result member, projected onto the requested type. + public T Result { get; } + + /// + /// The result member exactly as the node sent it. Empty when the response carried + /// none. + /// + public RawJson Raw { get; } + + /// The API version the node answered on, when it reported one. + public uint? ApiVersion { get; } + + /// + /// Warnings the node attached to this response. Never null. + /// + /// + /// rippled attaches these under load and on a reporting-mode server, and before this type + /// existed they did not survive the trip to the caller at all. + /// + public IReadOnlyList Warnings => _warnings ?? Array.Empty(); + + /// + /// True when a Reporting Mode server forwarded this request to a P2P server and back. + /// + public bool Forwarded { get; } + } +} +``` + +- [ ] **Step 4: Создать `ResolvedResponse`** + +`Xrpl/Client/ResolvedResponse.cs`: + +```csharp +using Xrpl.Models.Subscriptions; + +namespace Xrpl.Client +{ + /// + /// What a resolved request puts into its promise: the typed result and the envelope it came + /// from, together. + /// + /// + /// The promise is Task<object> and only knows the + /// target type as a , so it cannot build a + /// itself. It carries both halves this far and the generic + /// client assembles them, which keeps the manager free of the generic parameter. + /// + public sealed class ResolvedResponse + { + public ResolvedResponse(object result, BaseResponse envelope) + { + Result = result; + Envelope = envelope; + } + + public object Result { get; } + + public BaseResponse Envelope { get; } + } +} +``` + +- [ ] **Step 5: Класть `ResolvedResponse` в промис** + +В `Xrpl/Client/RequestManager.cs`: + +`:85` — заменить +```csharp + CompleteWithResult(taskInfo, deserialized); +``` +на +```csharp + CompleteWithResult(taskInfo, new ResolvedResponse(deserialized, response)); +``` + +`:398` (в `CreateRequest`, путь `Request(Dictionary)`) — заменить +```csharp + taskInfo.SetResult = result => task.TrySetResult((Dictionary)result); +``` +на +```csharp + taskInfo.SetResult = result => task.TrySetResult(result); +``` +и сменить тип `TaskCompletionSource>` на `TaskCompletionSource` в этом методе, а `XrplRequest.Promise` — с `Task>` на `Task`. Проверить фактические объявления в файле перед правкой и поправить согласованно. + +`:301` менять не нужно — там уже `task.TrySetResult(result)`. + +- [ ] **Step 6: Собрать `XrplResponse` в клиенте** + +В `Xrpl/Client/IXrplClient.cs` заменить реализацию `GRequest`: + +```csharp + public async Task> GRequest(R request, CancellationToken cancellationToken = default) where R : BaseRequest + { + request.ApiVersion ??= ApiVersion; + object resolved = await this.connection.GRequest(request, cancellationToken: cancellationToken); + return Wrap(resolved); + } + + /// + /// Turns what the request manager resolved into the response handed to the caller. + /// + private static XrplResponse Wrap(object resolved) + { + ResolvedResponse carried = (ResolvedResponse)resolved; + BaseResponse envelope = carried.Envelope; + + return new XrplResponse( + (T)carried.Result, + envelope?.RawResult ?? default, + envelope?.ApiVersion, + envelope?.Warnings, + envelope?.Forwarded ?? false); + } +``` + +и объявление в интерфейсе (`:419`): + +```csharp + Task> GRequest(R request, CancellationToken cancellationToken = default) where R : BaseRequest; +``` + +Аналогично `Request(Dictionary)` — интерфейс (`:418`) и реализация (`:906`) переходят на `Task>>`, а в конце реализации результат оборачивается тем же `Wrap>`. Точный вид тела посмотреть в файле: там есть работа с `api_version` до отправки, её не трогать. + +- [ ] **Step 7: Прогон только этой задачи** + +Сборка `Xrpl` на этом шаге ещё падает — 43 метода возвращают `Task`, а `GRequest` теперь отдаёт обёртку. Это ожидаемо и чинится в Task 3. + +Run: +```bash +dotnet build Xrpl/Xrpl.csproj -v q --nologo 2>&1 | grep -cE " error " +``` +Expected: ошибки только в `IXrplClient.cs` (43 метода) и в `Sugar`/`Wallet`. Убедиться, что среди них нет `RequestManager.cs` и `connection.cs` — если есть, доделать Step 5. + +**Не коммитить**: Task 2 и Task 3 атомарны, коммит один в конце Task 3. + +--- + +### Task 3: 43 сигнатуры клиента + +**Files:** +- Modify: `Xrpl/Client/IXrplClient.cs` — интерфейс и реализация + +- [ ] **Step 1: Заменить сигнатуры в интерфейсе** + +Каждое объявление вида +```csharp + Task AccountInfo(AccountInfoRequest request, CancellationToken cancellationToken = default); +``` +становится +```csharp + Task> AccountInfo(AccountInfoRequest request, CancellationToken cancellationToken = default); +``` + +Правило: `Task` → `Task>` для **всех** методов, делегирующих в `GRequest`. Не трогать: `IsConnected()`, `Connect()`, `Disconnect()`, `EnsureClassicAddress(string)`, `Dispose()`, сахарные методы, возвращающие вычисленные значения (`GetFeeXrp`, `GetLedgerIndex`, `GetXrpBalance` и подобные — они не проходят через `GRequest` напрямую; проверить каждый по телу реализации). + +Список кандидатов получить командой: +```bash +grep -nE "return this\.GRequest<" Xrpl/Client/IXrplClient.cs +``` +Каждая такая строка соответствует методу, чью сигнатуру надо сменить — и в реализации, и в интерфейсе. + +- [ ] **Step 2: Заменить сигнатуры в реализации** + +Тела не меняются: `return this.GRequest(request, cancellationToken);` продолжает работать, потому что `GRequest` теперь сам возвращает обёртку. Меняется только тип возврата у метода. + +- [ ] **Step 3: Собрать** + +Run: +```bash +dotnet build Xrpl/Xrpl.csproj -v q --nologo 2>&1 | grep -E " error " | sed -E 's/.*\\([A-Za-z]+\.cs)\(([0-9]+).*/\1/' | sort | uniq -c +``` +Expected: `IXrplClient.cs` исчез из списка; остались `Sugar/*` и `Wallet/*` — они чинятся в Task 4. + +**Не коммитить**: см. Task 2. + +--- + +### Task 4: Внутренние потребители — `Sugar` и `Wallet` + +**Files:** +- Modify: 10 файлов в `Xrpl/Sugar/` и `Xrpl/Wallet/` (18 вызовов) + +- [ ] **Step 1: Починить по списку компилятора** + +Каждый вызов вида +```csharp + AccountInfo data = await client.AccountInfo(request, cancellationToken); +``` +становится +```csharp + AccountInfo data = (await client.AccountInfo(request, cancellationToken)).Result; +``` + +Файлы (по `grep`): `Sugar/Autofill.cs`, `Sugar/Balances.cs`, `Sugar/ComposeSugar.cs`, `Sugar/DomainAccess.cs`, `Sugar/GetFeeXrp.cs`, `Sugar/GetLedgerIndex.cs`, `Sugar/Submit.cs`, `Wallet/FundWallet.cs`, `Wallet/LoanSigningHelper.cs`, `Wallet/SponsorSigningHelper.cs`. + +**Список неполон — это выяснилось при исполнении.** Сборка вскрыла ещё два файла: `Sugar/GetOrderBook.cs` (два вызова `BookOffers`) и `Models/Utils/BatchNormalizer.cs` (`AccountInfo`). А `Wallet/*` правок не потребовал вовсе — там имена методов встречаются только в XML-doc. Мораль для будущих планов: список, полученный `grep`-ом по одному шаблону вызова, — гипотеза, а не инвентарь; авторитетен список ошибок компилятора. + +**Не менять при этом семантику.** Если вызов был в составе выражения (`(await client.X(...)).Field`), скобки уже есть — добавляется только `.Result`. + +- [ ] **Step 2: Собрать всю библиотеку** + +Run: +```bash +dotnet build Xrpl/Xrpl.csproj -v q --nologo 2>&1 | grep -cE " error " +``` +Expected: `0` + +- [ ] **Step 3: Коммит группы 2-4** + +Это единственный коммит атомарной группы Task 2 → Task 4. + +```bash +git add Xrpl/Client/XrplResponse.cs Xrpl/Client/ResolvedResponse.cs Xrpl/Client/RequestManager.cs Xrpl/Client/IXrplClient.cs Xrpl/Sugar Xrpl/Wallet Tests/Xrpl.Tests/Client/XrplResponseTests.cs +git commit -m "feat(client)!: методы возвращают XrplResponse с сырым JSON и конвертом ответа" +``` + +--- + +### Task 5: Тесты + +**Files:** +- Modify: тесты в `Tests/Xrpl.Tests/` — около 520 мест + +- [ ] **Step 1: Починить по списку компилятора** + +Run: +```bash +dotnet build Tests/Xrpl.Tests/Xrpl.Tests.csproj -v q --nologo 2>&1 | grep -E " error " | head -40 +``` + +Правило то же: `X v = await client.M(...)` → `X v = (await client.M(...)).Result`; `var v = await client.M(...)` → либо `var v = (await client.M(...)).Result`, либо оставить обёртку, если тест дальше читает только поля результата — тогда обращения к полям получают `.Result`. + +**Не ослаблять ассерты ради компиляции.** Если тест перестал проверять то, что проверял, — это находка, остановиться и сообщить. + +- [ ] **Step 2: Прогон unit** + +Run: +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestU" +``` +Expected: **1009 пройдено** (1007 после Task 1 + 2 в `TestUXrplResponse`), 0 падений. Меньше — значит тест потерян. + +- [ ] **Step 3: Прогон интеграционных** + +```bash +docker compose -f .ci-config/docker-compose.ci.yml up -d +``` + +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestI" +``` +Expected: **265 пройдено**, 0 падений. + +```bash +docker compose -f .ci-config/docker-compose.ci.yml down +``` + +- [ ] **Step 4: Коммит** + +```bash +git add Tests/ +git commit -m "test: перевести вызовы клиента на XrplResponse" +``` + +--- + +### Task 6: Закрепить сокетный бюджет тестом — ЗАКРЫТ существующим тестом + +**Проверено, отдельный тест не нужен.** В `TestUResponseParsing` уже есть `TestSocketPathKeepsResponsesInTheirWireForm` — гоняет 20 страниц по мегабайту через `Connection` над реальным сокетом, помечен `[DoNotParallelize]`, порог 3.0. Замер после уровня 1: **2.18x**, то есть путь не ухудшился и тест ловит возврат строковой перегрузки (она давала 4.84x). + +Вместе с `TestTypedResponseParsingStaysWithinItsAllocationBudget` (5.57x при пороге 6.5) покрытие полное: первый защищает выбор байтовой перегрузки на сокете, второй — отсутствие промежуточного документа на типизированном пути. Дублировать нечем. + +Оставлено как есть; ниже — исходная формулировка задачи для истории. + +### Task 6 (исходная формулировка) + +Перенос 4 из финального ревью уровня 0: главное достижение — 92 736 → 2 432 B на сообщение — сейчас держится только текстом в `CHANGES.md`. + +**Files:** +- Modify: `Tests/Xrpl.Tests/Client/TestUResponseParsing.cs` + +- [ ] **Step 1: Написать тест** + +Инфраструктура уже есть: `PagedResponseServer` используется в этом файле на строках 341, 378 и 436, и один из тех тестов уже гоняет страницы через `Connection` над реальным сокетом, читая process-wide счётчик и потому помеченный `[DoNotParallelize]`. Взять его как образец. + +Новый тест должен отличаться от существующего одним: он меряет **байтовый** путь как отношение аллокаций к длине сообщения и падает, если отношение вырастет. Существующий разделяет байтовый и строковый пути порогами 2.18x/4.84x, но не защищает достигнутое здесь — 2 432 B на сообщение против 92 736 до уровня 0. + +Порядок: написать тест с заведомо слабым порогом (например 3.0), прогнать, записать фактическое значение, вписать «измеренное + 0.5». Не угадывать и не переносить сюда числа из `CHANGES.md` — они сняты на другой форме сообщения. + +- [ ] **Step 2: Прогнать трижды** + +Run: +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "AllocationBudget" +``` +Трижды подряд, все три зелёные. Если мигает — тест меряет process-wide память и требует `[DoNotParallelize]`, как `TestUEnvelopeRetainsNoMoreThanTheFrame`. + +- [ ] **Step 3: Коммит** + +```bash +git add Tests/Xrpl.Tests/Client/TestUResponseParsing.cs +git commit -m "test(client): закрепить бюджет сокетного пути" +``` + +--- + +### Task 7: `CHANGES.md` и приёмка + +**Files:** +- Modify: `CHANGES.md` + +- [ ] **Step 1: Дописать раздел** + +В существующий `## Unreleased` добавить уровень 1: смена возвращаемого типа 43 метода, `Request(Dictionary)`, `GRequest`, замена сеттера `Frame` на `AttachFrame`, новые публичные типы. Обязательно — код «было → стало», как это сделано для `Result`: + +```csharp +// было +AccountInfo info = await client.AccountInfo(request); + +// стало +AccountInfo info = (await client.AccountInfo(request)).Result; + +// а теперь доступно и то, ради чего всё делалось +XrplResponse response = await client.AccountInfo(request); +string asTheNodeSentIt = response.Raw.ToString(); +``` + +Отдельно назвать: почему нет `implicit operator` (с цифрами 273/248), и что `Warnings` теперь доходят до вызывающего, а раньше терялись. + +**Не менять кодировку файла** — в `dev` он без BOM, проверить `head -c 3 CHANGES.md | xxd` после правки. + +- [ ] **Step 2: Финальная приёмка** + +```bash +dotnet build Xrpl/Xrpl.csproj -v q --nologo +``` +Expected: 0 ошибок, все три TFM. + +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestU" +``` +Expected: не менее 1009, 0 падений. + +- [ ] **Step 3: Коммит** + +```bash +git add CHANGES.md +git commit -m "docs(changes): уровень 1 — XrplResponse и миграция вызовов" +``` + +--- + +## Матрица покрытия + +| Член | Тест | +|---|---| +| `RawJson.Deserialize()` | `TestURawJsonDeserializesWithLibraryOptions` | +| `RawJson.Deserialize()` на пустом окне | `TestURawJsonDeserializeOnAnEmptyWindowReturnsDefault` | +| `RawJson.ToJsonElement()` самодостаточен | `TestURawJsonToJsonElementOwnsItsData` | +| `RawJson.ToJsonElement()` на пустом окне | `TestURawJsonToJsonElementOnAnEmptyWindowIsUndefined` | +| `RawJson.HasTopLevelProperty` | `TestURawJsonFindsTopLevelPropertiesOnly` | +| `BaseResponse.AttachFrame` отвергает чужой кадр | `TestUAttachFrameRejectsAFrameThatDoesNotFitTheSlice` | +| `HasNextPage` после переноса на общий скан | десять существующих `TestUHasNextPage` — обязаны пройти без правок | +| `XrplResponse` несёт `Result` и `Raw` | `TestUCarriesResultAndRawSideBySide` | +| `XrplResponse.Warnings` не null | `TestUWarningsAreNeverNull` | +| сокетный бюджет | Task 6 | +| весь конвейер end-to-end | 265 интеграционных | + +--- + +## Что этот план сознательно не делает + +- **Не трогает `BaseResponse.Id` и `ErrorResponse.Request`** — они всё ещё `object` и держат по 3 672 B и ~360 B невозвращаемой аренды. Это уровень 2, вместе с nullability. +- **Не отдаёт сырой конверт целиком** (`RawEnvelope` поверх всего кадра). Вся проблема из спеки лежит внутри `result`; конверт понадобится, только если появится потребитель для сырых `warnings`. +- **Не решает политику удержания кадра при постраничном обходе.** Каждый удержанный `XrplResponse` пиннит весь кадр. Для `account_tx` кадр и есть `result`, так что вопрос встанет на мелких ответах, которые копят — тогда и назвать политику, задокументировав `Raw.ToArray()` как выход. +- **Не делает поля моделей nullable и не добавляет `[JsonExtensionData]`** — уровень 2. +- **Не разводит v1/v2** (`Amount`/`DeliverMax`, `tx`/`tx_json`, `meta`/`meta_blob`) — уровень 3. diff --git a/plans/2026-08-17-raw-response-level2.md b/plans/2026-08-17-raw-response-level2.md new file mode 100644 index 00000000..13f5841e --- /dev/null +++ b/plans/2026-08-17-raw-response-level2.md @@ -0,0 +1,415 @@ +# Raw Response, уровень 2: модель перестаёт врать + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Убрать из типизированной модели значения, которых узел не присылал, и перестать молча терять поля, которых модель не знает. + +**Architecture:** Два независимых дефекта, лечатся по-разному. Приписанные нули — от non-nullable CLR-свойств: лечится nullability, а объём определяется не на глаз, а по vendored `ledger_entries.macro` из rippled, который уже лежит в репозитории вместе с парсером требуемости. Молча теряемые поля — отсутствием места, куда их положить: лечится `[JsonExtensionData]`. Плюс известный остаток уровня 0 — `BaseResponse.Id` и `ErrorResponse.Request` всё ещё `object`. + +**Tech Stack:** .NET 8/9/10, System.Text.Json, MSTest 4.0.2 (`Assert.ThrowsExactly`; `ImplicitUsings` выключен). + +**Базовая линия:** 1018 unit-тестов и 265 интеграционных, 0 падений. `dotnet build XrplCSharp.sln` — 0 ошибок (это гейт CI, приёмка проверяет именно его, а не отдельные проекты). + +--- + +## Замеры, на которых стоит план + +Сняты разведочным прогоном по `RippledLedgerEntryFormats.Parse()` и рефлексии над моделями: + +| Что | Число | +|---|---| +| Опциональных/дефолтных полей ledger-объектов в протоколе | 160 | +| Из них модель **не может выразить отсутствие** — прямое нарушение | **9** | +| Всего свойств в ledger-моделях | 449 | +| Из них non-nullable value-типа | **80** | +| Non-nullable value-свойств в `Models/Transactions` | 51 | + +**Девять прямых нарушений** (поле объявлено `Optional`/`Default`, свойство не может быть пустым): + +``` +AMM.TradingFee : UInt32 (Default) +FeeSettings.ReferenceFeeUnits : UInt32 (Optional) +FeeSettings.ReserveBase : UInt32 (Optional) +FeeSettings.ReserveIncrement : UInt32 (Optional) +LedgerHashes.FirstLedgerSequence : UInt32 (Optional) +LedgerHashes.LastLedgerSequence : UInt32 (Optional) +MPTokenIssuance.AssetScale : Byte (Default) +PayChannel.SourceTag : UInt32 (Optional) +PayChannel.DestinationTag : UInt32 (Optional) +``` + +## Почему nullable нужна не только этим девяти + +Требуемость поля в протоколе описывает **сам ledger-объект**. Но те же модели переиспользуются как содержимое `PreviousFields`, `FinalFields` и `NewFields`, а это **частичные проекции**: `PreviousFields` по протоколу несёт только изменившиеся члены. Там отсутствовать может и обязательное поле — именно так `Flags`, `OwnerCount`, `Sequence`, `PreviousTxnLgrSeq` и `LedgerEntryType` появляются в реконструкции нулями, которых узел не присылал (156 приписанных членов на десяти записях `account_tx`, см. спеку §2). + +Отсюда правило уровня: **любое value-свойство модели, которая может оказаться частичной проекцией, обязано быть nullable.** Это все 80 в ledger-моделях и 51 в транзакциях. Разделять «эти nullable, эти нет» — значит принимать 131 ручное решение и удерживать их согласованными вручную; тест соответствия дешевле и не забывает. + +--- + +## File Structure + +**Создаются:** + +- `Tests/Xrpl.Tests/Models/TestUNullabilityConformance.cs` — класс `TestUNullabilityConformance`. Две проверки: свойство под `Optional`/`Default` полем обязано быть nullable (авторитетно, по macro); любое value-свойство ledger-модели обязано быть nullable (правило частичной проекции). Пишется **первым** и сначала краснеет. + +**Изменяются:** + +- `Xrpl/Models/Ledger/*.cs` — 80 свойств. +- `Xrpl/Models/Transactions/*.cs` — 51 свойство, включая `NodeBase.LedgerEntryType`. +- `Xrpl/Models/Subscriptions/BaseResponse.cs`, `ErrorResponse.cs` — `Id` и `Request` уходят с `object`. +- `Xrpl/Sugar/*`, `Xrpl/Wallet/*` — места, где `uint` станет `uint?`. +- Тесты, демо-проекты — по списку компилятора. +- `CHANGES.md`. + +**Breaking — поимённо, без мостиков** (политика мажора, см. спеку): + +| Член | Судьба | +|---|---| +| ~131 value-свойство моделей | `T` → `T?` | +| `BaseResponse.Id` (`object?`) | → строго типизированное, см. Task 4 | +| `ErrorResponse.Request` (`object`) | → `JsonSlice` + `RawRequest`, как `result` | + +--- + +### Task 1: Тест соответствия nullability + +Пишется первым: он определяет объём и защищает от регресса. + +**Files:** +- Create: `Tests/Xrpl.Tests/Models/TestUNullabilityConformance.cs` + +- [ ] **Step 1: Написать тест** + +```csharp +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json.Serialization; + +namespace Xrpl.Tests.Models.Tests +{ + /// + /// Holds the models to what the protocol says can be absent. A non-nullable CLR property cannot + /// express absence, so it re-serializes as a zero the node never sent — which is the whole + /// defect this level exists to remove. + /// + /// + /// Two rules, and the second is broader than the first on purpose. rippled's requirement flag + /// describes the ledger object; the same models also carry PreviousFields, + /// FinalFields and NewFields, which are partial projections — PreviousFields + /// holds only the members a transaction changed, so even a Required field can be missing there. + /// + [TestClass] + public class TestUNullabilityConformance + { + private static Dictionary Models() + { + FieldInfo field = typeof(TestULedgerEntryFieldsConformance) + .GetField("Models", BindingFlags.NonPublic | BindingFlags.Static); + return (Dictionary)field.GetValue(null); + } + + private static PropertyInfo FindProperty(Type model, string protocolField) + { + foreach (PropertyInfo property in model.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + JsonPropertyNameAttribute name = property.GetCustomAttribute(); + string mapped = name?.Name ?? property.Name; + if (string.Equals(mapped, protocolField, StringComparison.Ordinal)) + { + return property; + } + } + + return null; + } + + private static bool CannotExpressAbsence(PropertyInfo property) + { + Type type = property.PropertyType; + return type.IsValueType && Nullable.GetUnderlyingType(type) is null; + } + + /// + /// A field rippled declares Optional or Default must map to a property that can be absent. + /// Authoritative: the requirement comes from the vendored ledger_entries.macro. + /// + [TestMethod] + public void TestUOptionalProtocolFieldsMapToNullableProperties() + { + Dictionary> formats = + RippledLedgerEntryFormats.Parse(); + Dictionary models = Models(); + List offenders = new List(); + + foreach (KeyValuePair pair in models.OrderBy(p => p.Key, StringComparer.Ordinal)) + { + if (!formats.TryGetValue(pair.Key, out Dictionary fields)) + { + continue; + } + + foreach (KeyValuePair field in fields) + { + if (field.Value == RippledLedgerEntryFormats.Requirement.Required) + { + continue; + } + + PropertyInfo property = FindProperty(pair.Value, field.Key); + if (property is not null && CannotExpressAbsence(property)) + { + offenders.Add($"{pair.Key}.{field.Key} is {field.Value} but {property.PropertyType.Name} cannot be absent"); + } + } + } + + Assert.AreEqual( + 0, + offenders.Count, + "a field the protocol allows to be absent must not re-serialize as a default:" + + Environment.NewLine + string.Join(Environment.NewLine, offenders)); + } + + /// + /// Every value-typed property of a ledger-entry model must be nullable, whatever the + /// protocol says about the object itself. + /// + /// + /// Broader than the rule above because these models double as the contents of + /// PreviousFields/FinalFields/NewFields. PreviousFields carries only what a transaction + /// changed, so any member can be missing there and a non-nullable property fabricates a + /// value for it — that is where the 156 invented members on a ten-entry account_tx came from. + /// + [TestMethod] + public void TestULedgerEntryPropertiesCanAllExpressAbsence() + { + List offenders = new List(); + + foreach (KeyValuePair pair in Models().OrderBy(p => p.Key, StringComparer.Ordinal)) + { + foreach (PropertyInfo property in pair.Value.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (property.GetCustomAttribute() is not null) + { + continue; + } + + if (CannotExpressAbsence(property)) + { + offenders.Add($"{pair.Key}.{property.Name} : {property.PropertyType.Name}"); + } + } + } + + Assert.AreEqual( + 0, + offenders.Count, + "these appear in PreviousFields/FinalFields, where absence is normal:" + + Environment.NewLine + string.Join(Environment.NewLine, offenders)); + } + } +} +``` + +- [ ] **Step 2: Прогнать, зафиксировать провал** + +Run: +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestUNullabilityConformance" +``` +Expected: **оба теста красные** — первый перечисляет 9 нарушений, второй 80. Сохрани полный вывод второго: это рабочий список для Task 2. + +- [ ] **Step 3: Коммит** + +```bash +git add Tests/Xrpl.Tests/Models/TestUNullabilityConformance.cs +git commit -m "test(models): тест соответствия nullability — сейчас красный, фиксирует объём" +``` + +Красный тест в истории — намеренно: он документирует дефект до починки. Следующая задача делает его зелёным. + +--- + +### Task 2: Ledger-модели — 80 свойств + +**Files:** +- Modify: `Xrpl/Models/Ledger/*.cs` + +**Два решения, принятые по результатам Task 1 — принимай их, не переоткрывай:** + +1. **`LedgerEntryType` делаем nullable** (28 из 80 нарушений). Возражение справедливо: для полноценного ledger-объекта поле присутствует всегда, и в macro конкретного объекта его нет — оно общее. Но модель одна и та же служит содержимым `PreviousFields`, где его нет, и спека §2 фиксирует `PreviousFields.LedgerEntryType` среди приписанных членов **на живом ответе mainnet**. То есть дефект не теоретический. Потребитель полного объекта пишет `.Value` там, где раньше читал напрямую, — цена честности. + +2. **`LONFTokenPage.PreviousTxnLgrSeq` меняем `long` → `uint?`, а не `long?`.** Проверено по `definitions.json`: поле объявлено `UInt32`, и во всех остальных моделях оно `uint`. `long` здесь шире протокола и несогласован с соседями — правится заодно, отдельной строкой в `CHANGES.md`. + +- [ ] **Step 1: Править по списку из Task 1** + +Каждое value-свойство ledger-модели: `uint` → `uint?`, `int` → `int?`, `bool` → `bool?`, `byte` → `byte?`, `DateTime` → `DateTime?`, enum → `Enum?`. + +**Не трогай** свойства с `[JsonIgnore]` и вычисляемые (без сеттера) — тест их пропускает, и они не участвуют в сериализации. + +Порядок — по файлам, начиная с крупнейших: `PayChannel` (6), `AccountRoot` (5), `FeeSettings` (5), `MPTokenIssuance` (5), `SignerList` (5), `LedgerHashes` (4), `Offer` (4). + +- [ ] **Step 2: Прогнать тест соответствия** + +Run: +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestUNullabilityConformance" +``` +Expected: оба зелёные. + +- [ ] **Step 3: Починить сборку решения** + +Смена типа сломает потребителей — `Sugar`, тесты, демо-проекты. Правило: где значение действительно нужно, `.Value` или `?? default` **с осознанным выбором дефолта**; где логика допускает отсутствие, — проверка на null. + +**Не глуши `.Value` вслепую.** Если поле теперь может быть null, а код на это не рассчитан, — это находка, а не помеха: покажи её. + +Run: +```bash +dotnet build XrplCSharp.sln -v q --nologo +``` +Expected: 0 ошибок. Это гейт CI — проверяй именно решение, а не отдельные проекты. + +- [ ] **Step 4: Прогон** + +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestU" +``` +Expected: не менее 1020 (1018 + 2 новых), 0 падений. + +- [ ] **Step 5: Коммит** + +```bash +git add Xrpl/Models/Ledger Xrpl/Sugar Xrpl/Wallet Tests XrplCSharp.sln +git commit -m "fix(models)!: ledger-модели больше не приписывают нули отсутствующим полям" +``` + +--- + +### Task 3: Транзакции — 51 свойство + +**Files:** +- Modify: `Xrpl/Models/Transactions/*.cs` + +- [ ] **Step 1: Расширить тест на транзакции** + +Добавь в `TestUNullabilityConformance` третий тест по образцу второго, но по моделям транзакций. + +**Готового маппинга «тип транзакции → модель» в репозитории нет** — проверено. `TestUTxFormatConformance` сравнивает `TxFormat.Formats` (реестр `Dictionary` в самом SDK) с `RippledTransactionFormats.Parse()`, то есть таблицу с таблицей, минуя C#-модели. + +Единственный существующий реестр «имя → модель» — switch в `Xrpl/Client/Json/Converters/TransactionResponseConverter.cs` (`"AccountSet" => new AccountSetResponse()`), из которого видно соглашение: response-модель называется `Response` в namespace `Xrpl.Models.Transactions`. + +Строй маппинг рефлексией по этому соглашению, проходя по именам из `TxFormat.Formats`, и **падай, если модель для объявленного типа не найдена** — иначе новый тип транзакции молча выпадет из проверки, ровно как это уже случалось с полями (см. комментарий в `TestULedgerEntryFieldsConformance` про `sfLEVersion`). Требуемость бери из `RippledTransactionFormats`. + +Проверять надо именно response-модели: они приходят от узла и именно они переиспользуются в метаданных. + +Отдельно включи `NodeBase.LedgerEntryType`: спека фиксирует его как приписываемый в `PreviousFields`, а сам `NodeBase` не является ledger-моделью и во второй тест не попадает. + +- [ ] **Step 2: Прогнать, зафиксировать провал, затем править** + +Те же правила, что в Task 2. + +- [ ] **Step 3: Сборка решения и прогон** + +```bash +dotnet build XrplCSharp.sln -v q --nologo +``` +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestU" +``` + +- [ ] **Step 4: Коммит** + +```bash +git add Xrpl/Models/Transactions Tests +git commit -m "fix(models)!: модели транзакций и узлов метаданных не приписывают значений" +``` + +--- + +### Task 4: Остаток уровня 0 — `Id` и `Request` + +**Files:** +- Modify: `Xrpl/Models/Subscriptions/BaseResponse.cs`, `Xrpl/Models/Subscriptions/ErrorResponse.cs` +- Modify: `Xrpl/Client/RequestManager.cs` + +Замерено на уровне 0: конверт с `"id"` удерживает **3 672 B**, без него — **217 B**. Разница — `JsonElement`, который STJ строит для `object`-свойства, с невозвращаемой арендой из `ArrayPool`, на **каждом** ответе. `ErrorResponse.Request` — то же на ветке ошибок, ~360 B, а `Sugar/Submit.cs` ловит `txnNotFound` в цикле опроса. + +- [ ] **Step 1: Тест бюджета** + +Ужесточить `TestUEnvelopeRetainsNoMoreThanTheFrame`: порог 8192 стоял выше известного остатка. После этой задачи снять фактическое значение и вписать «измеренное + запас». Прогнать трижды. + +- [ ] **Step 2: `Id`** + +`RequestManager` уже приводит его к `Guid` через `Guid.TryParse($"{response.Id}")` — то есть форматирует `JsonElement` в строку на каждом ответе. Перевести на `JsonSlice` тем же `JsonSliceConverter` плюс `RawId`, либо на строгий тип. Выбор обосновать в коммите: `id` в протоколе может быть строкой или числом. + +- [ ] **Step 3: `Request`** + +`ErrorResponse.Request` — эхо запроса, содержимое произвольное. Перевести на `JsonSlice` + публичное `RawRequest` типа `RawJson`, ровно как сделано для `result`. + +- [ ] **Step 4: Сборка, прогон, коммит** + +--- + +### Task 5: `[JsonExtensionData]` — неизвестные поля перестают исчезать + +**Files:** +- Modify: `Xrpl/Models/Ledger/BaseLedgerEntry.cs` и модели транзакций + +- [ ] **Step 1: Проверить совместимость с конвертерами — сделать ДО правок** + +`[JsonExtensionData]` не работает автоматически на типе с кастомным `JsonConverter`: конвертер сам управляет чтением. В репозитории такие есть — `LOConverter`, `ModifiedNodeConverter`, `CreatedNodeConverter`, `DeletedNodeConverter`, `TransactionResponseConverter`, `LONFTokenConverter`. + +Прогнать разведку: добавить `[JsonExtensionData] public Dictionary UnknownFields { get; set; }` в одну модель, скормить ответ с неизвестным полем и проверить, попало ли оно. Результат определяет объём Task 5: если конвертеры глушат extension data, их придётся учить ей, и это отдельная работа. + +**Не переходить к Step 2, пока это не выяснено фактически.** + +- [ ] **Step 2: По результату разведки — либо добавить, либо переоценить задачу** + +Если extension data работает мимо конвертеров — добавить на `BaseLedgerEntry` и базовые модели транзакций, с тестом: ответ с полем, которого нет в модели, сохраняет его в `UnknownFields`. + +Если не работает — остановиться, доложить, и решать отдельно: возможно, `Raw` уровня 1 закрывает потребность и extension data не нужна вовсе. + +--- + +### Task 6: `CHANGES.md` и приёмка + +- [ ] **Step 1: Раздел с кодом «было → стало»** + +Обязательно показать самый частый случай: `uint x = entry.Sequence;` → `uint x = entry.Sequence ?? 0;` — и предупредить, что молчаливая подстановка нуля возвращает ровно тот дефект, ради которого всё делалось; там, где ноль не является осмысленным, нужна проверка. + +Назвать числа: 9 прямых нарушений протокола, 80 + 51 свойство, 3 672 B на ответ от `Id`. + +- [ ] **Step 2: Приёмка гейтом CI** + +```bash +dotnet build XrplCSharp.sln -v q --nologo +``` +Expected: 0 ошибок. + +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestU" +``` + +```bash +docker compose -f .ci-config/docker-compose.ci.yml up -d +``` +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestI" +``` +Expected: 265, 0 падений. +```bash +docker compose -f .ci-config/docker-compose.ci.yml down +``` + +--- + +## Что этот план сознательно не делает + +- **Не разводит v1/v2** (`Amount`/`DeliverMax`, `tx`/`tx_json`, `meta`/`meta_blob`, `Tx()` с жёстким `api_version = 1`) — уровень 3. +- **Не добавляет CI-проверку fidelity** на корпусе живых ответов — уровень 4. +- **Не трогает стримы.** `subscribe` и догоны `path_find` идут через `EnqueueStreamMessage(Text())` и сырого текста не получили; отмечено финальным ревью уровня 1 как остаток. +- **Не выводит наружу `status`** конверта — уровень 1 вернул `ApiVersion`, `Warning`, `Warnings`, `Forwarded`, а `status` остался. Через `Raw` он недостижим (он вне `result`). diff --git a/plans/2026-08-17-raw-response-level3.md b/plans/2026-08-17-raw-response-level3.md new file mode 100644 index 00000000..5707486c --- /dev/null +++ b/plans/2026-08-17-raw-response-level3.md @@ -0,0 +1,245 @@ +# Raw Response, уровень 3: развод API v1 и v2 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Убрать последнее, чем типизированная модель искажает ответ узла: подмену имён между версиями API и поля, для которых в моделях просто нет места. + +**Architecture:** Уровни 0–2 сняли двойной разбор, отдали сырые байты и убрали приписки. Осталось три класса дефектов, все связанные с версиями API: имя поля подменяется при round-trip (`Amount` вместо `DeliverMax`), поля не смоделированы вовсе (`meta_blob`, `tx_blob`, `ctid`), и выбор метода молча меняет версию протокола (`Tx()` игнорирует настройку клиента). + +**Tech Stack:** .NET 8/9/10, System.Text.Json, MSTest 4.0.2 (`Assert.ThrowsExactly`; `ImplicitUsings` выключен). + +**Приёмка:** `dotnet build XrplCSharp.sln` (гейт CI, не отдельные проекты), unit-прогон, интеграционные на живом rippled. + +--- + +## Что установила разведка — и что из этого следует + +**Механизм алиасов v1/v2 уже существует** и сделан не этой инициативой, а релизом 10.9.1.0. Приватный set-only алиас с `[JsonInclude]`: + +| Модель | v2-имя | v1-алиас | Файл | +|---|---|---|---| +| `Payment` / `PaymentResponse` | `Amount` | `DeliverMax` | `Models/Transactions/Payment.cs:59-75, 199-217` | +| `TransactionSummary` | `tx_json` | `tx` | `Models/Methods/AccountTransactions.cs:141, 156` | +| `TransactionStream` | `tx_json` | **`transaction`** | `Models/Subscriptions/TransactionStream.cs:92, 107` | + +Обрати внимание на третью строку: в стриме v1-конверт называется `transaction`, а в `account_tx` — `tx`. Одно явление, два разных имени. + +**Чего нет вообще:** + +- `meta_blob` и `tx_blob` — `grep` по `Xrpl/` даёт ноль. Под API v2 с `binary: true` узел присылает их отдельными полями верхнего уровня, поля `meta` в ответе нет, и строковая ветка `MetaBinaryConverter.Read` (`Converters/MetaBinaryConverter.cs:35-53`) не срабатывает **никогда**. Замер: `TransactionSummary` теряет ответ целиком, 2246 B → 195 B. +- `ctid` в ответе — есть только `TxRequest.CtId` (`Models/Methods/Tx.cs:28`), на стороне ответа нигде. +- `close_time_iso` в `TransactionResponse`/`BaseTransactionResponse` — есть в `TransactionSummary`, `TransactionStream`, `LOLedger`, но не в v1-модели транзакции. +- `status` в `XrplResponse` — `BaseResponse.Status` существует, но `XrplResponse.From` его не переносит (`Client/XrplResponse.cs:151-162`). Через `Raw` недостижим: `Raw` — срез `result`, а `status` вне его. + +**`Tx()` жёстко ставит `api_version = 1`** (`Client/IXrplClient.cs:889`), игнорируя `ClientOptions.ApiVersion`, который по умолчанию равен 2. Внутри SDK `Tx()` не вызывается ни разу — единственный внутренний вызов это `TxV2` в `Sugar/Submit.cs:419`. + +**Уже закрыто уровнем 2, перепроверено прогоном:** `DeletedNode.FinalFields.Flags` больше не теряется. Спека фиксировала этот дефект на состоянии до nullability. Остаточный канал потери — `LedgerEntryType`, не входящий в switch `GetTypeForLedgerEntry` (`Converters/LedgerObjectConverter.cs:176-210`): такой объект падает в голый `BaseLedgerEntry`, у которого нет ничего, кроме трёх свойств. Это общий механизм, не специфичный для `DeletedNode`. + +--- + +## Решения, принятые для этого уровня + +> **Уточнено при исполнении:** одного флага «пришло как DeliverMax» недостаточно. +> API v1 присылает **оба** имени для одного значения (проверено на живом mainnet), +> и один флаг затирался бы тем сеттером, который STJ вызовет вторым, — одно из имён +> терялось бы. В коде два независимых флага присутствия, и случай «пришли оба» даёт +> на выходе оба. Фикстура `tx_v1_raw.json` в корпусе закрывает именно этот случай. + +**1. `Amount`/`DeliverMax` — запоминаем, какое имя пришло.** Сейчас модель хранит одно поле и при обратной сериализации всегда пишет `Amount`. Спека справедливо называет это худшим случаем: не потеря, а **подмена на другое валидное имя протокола**, которую подпись «(reconstructed)» не покрывает. Чиним: модель запоминает, под каким именем поле пришло, и пишет обратно его же. Цена — одно приватное поле и условная сериализация. + +**2. `Tx()` переименовывается в `TxV1()`.** Метод не станет уважать `ClientOptions.ApiVersion`: он привязан к v1-модели `TransactionResponse`, и отдать в неё v2-ответ значит потерять `tx_json` целиком. Но имя обязано говорить правду. `Tx` → `TxV1`, рядом остаётся `TxV2`; молчаливого расхождения с настройкой клиента больше нет, потому что версия названа в имени метода. Политика мажора позволяет — переходных мостиков не заводим. + +**3. `meta_blob`, `tx_blob`, `ctid`, `close_time_iso`, `status` — просто добавляем.** Чистые пробелы, спорить не о чем. + +**4. Сырое для стримов — не в этот уровень.** `TransactionStream` не имеет `Raw`, потому что стрим-сообщения идут через `EnqueueStreamMessage(Text())` (`connection.cs:3267, 3302`) — там уже UTF-16 строка, кадра нет. Дать стримам ту же точность значит переписать путь стримов на байты, а это по объёму сопоставимо с уровнем 0. Выносится отдельным пунктом в «не делает», с обоснованием. + +--- + +## Breaking — поимённо, без мостиков + +| Член | Судьба | +|---|---| +| `IXrplClient.Tx(TxRequest, CancellationToken)` | **переименован** в `TxV1` | +| `MetaBinaryConverter` | остаётся, но перестаёт быть единственным путём для binary — см. Task 2 | + +--- + +### Task 1: Пробелы, которых просто нет + +Самое дешёвое и бесспорное — делаем первым. + +**Files:** +- Modify: `Xrpl/Models/Transactions/BaseTransactionResponse.cs` — `close_time_iso`, `ctid` +- Modify: `Xrpl/Models/Methods/AccountTransactions.cs` — `ctid` в `TransactionSummary` +- Modify: `Xrpl/Client/XrplResponse.cs` — `Status` +- Test: `Tests/Xrpl.Tests/Models/` + +- [ ] **Step 1: Тесты на живых данных** + +В `Tests/Xrpl.Tests/Fixtures/Responses/` лежат реальные ответы mainnet (`tx_raw.json`, `account_tx_raw.json`). Возьми из них фактические значения `close_time_iso` и `ctid` и напиши тесты: десериализация ответа сохраняет оба поля, round-trip их не теряет. + +- [ ] **Step 2: Добавить свойства** + +`close_time_iso` — тип `DateTime?` с `[JsonConverter(typeof(FromStringDateTimeConverter))]`, как это уже сделано в `TransactionSummary` (`AccountTransactions.cs:103-105`). Скопируй форму оттуда, не изобретай. + +`ctid` — `string?`, `[JsonPropertyName("ctid")]`. + +`Status` в `XrplResponse` — новый член плюс параметр конструктора; `XrplResponse.From` переносит `envelope?.Status`. Порядок параметров конструктора выбери так, чтобы `Status` встал рядом с прочими членами конверта, и **поправь все существующие вызовы конструктора**, включая тесты. + +- [ ] **Step 3: Приёмка и коммит** + +```bash +dotnet build XrplCSharp.sln -v q --nologo +``` +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestU" +``` +```bash +git commit -m "feat(models): close_time_iso, ctid и status больше не теряются" +``` + +--- + +### Task 2: `meta_blob` и `tx_blob` — binary-режим API v2 + +**Files:** +- Modify: `Xrpl/Models/Methods/AccountTransactions.cs` (`TransactionSummary`) +- Modify: `Xrpl/Models/Transactions/BaseTransactionResponse.cs` + +- [ ] **Step 1: Тест на реальном ответе** + +В scratchpad есть `tx_binary_raw.json` — реальный ответ mainnet при `binary: true, api_version: 2`. Его форма: + +```json +{"result":{"close_time_iso":"...","ctid":"...","hash":"...","ledger_hash":"...", + "ledger_index":348734,"meta_blob":"201C...","status":"success", + "tx_blob":"1200002200...","validated":true}} +``` + +Напиши тест: десериализация этого ответа сохраняет `meta_blob` и `tx_blob`. Сейчас он упадёт — 2246 B схлопываются в 195 B. + +- [ ] **Step 2: Добавить свойства** + +`string? MetaBlob` с `[JsonPropertyName("meta_blob")]` и `string? TxBlob` с `[JsonPropertyName("tx_blob")]`. + +**Не трогай `MetaBinaryConverter`.** Его строковая ветка обслуживает v1 (`"meta": ""`) и работает корректно; v2 присылает другие поля, и им нужны свои свойства. Задокументируй это в XML-doc обоих новых членов: под какой версией и каким флагом они приходят. + +- [ ] **Step 3: Приёмка и коммит** + +```bash +git commit -m "feat(models): meta_blob и tx_blob API v2 больше не теряются" +``` + +--- + +### Task 3: `Amount` / `DeliverMax` — модель перестаёт подменять имя + +Самая содержательная часть уровня. + +**Files:** +- Modify: `Xrpl/Models/Transactions/Payment.cs` +- Test: `Tests/Xrpl.Tests/Models/` + +- [ ] **Step 1: Тест, фиксирующий подмену** + +На реальном фрагменте: ответ v2 несёт `"DeliverMax": {...}` и **не** несёт `Amount`. Тест: после round-trip выходной JSON содержит `DeliverMax` и не содержит `Amount`. Сейчас упадёт — сериализуется `Amount`. + +Второй тест, симметричный: ответ v1 несёт `Amount`, round-trip даёт `Amount` и не даёт `DeliverMax`. + +- [ ] **Step 2: Запомнить имя, под которым пришло** + +Сейчас (`Payment.cs:59-75`, `199-217`): + +```csharp +[JsonConverter(typeof(CurrencyConverter))] +public Currency Amount { get; set; } + +[JsonInclude] +[JsonPropertyName("DeliverMax")] +[JsonConverter(typeof(CurrencyConverter))] +private Currency? DeliverMax +{ + set => Amount = value; +} +``` + +Добавь приватное поле, отмечающее, что значение пришло под именем `DeliverMax`, и сделай сериализацию условной: выводить `Amount`, когда пришло `Amount` или объект собран кодом; выводить `DeliverMax`, когда пришло оно. + +Условная сериализация в System.Text.Json делается через `ShouldSerialize`-подобный приём: свойство остаётся, но помечается `[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]`, а рядом заводится второе, зеркальное. Выбери реализацию по месту — важно поведение, а не приём. Если окажется, что чисто атрибутами это не выражается, напиши маленький `JsonConverter` на `Payment`/`PaymentResponse`, но тогда **проверь, что он не ломает** существующий `TransactionResponseConverter`, который выбирает подтип. + +**Свойство `Amount` остаётся публичным и единственным для чтения** — потребитель не должен гадать, откуда брать сумму. Меняется только то, под каким именем она уходит обратно. + +- [ ] **Step 3: Проверить на живых данных** + +Прогон по `account_tx_raw.json`: было 4 приписанных `tx_json.Amount` и 4 потерянных `tx_json.DeliverMax`. Должно стать 0 и 0. + +- [ ] **Step 4: Приёмка и коммит** + +```bash +git commit -m "fix(models)!: Payment не подменяет DeliverMax на Amount при обратной сериализации" +``` + +--- + +### Task 4: `Tx` → `TxV1` + +**Files:** +- Modify: `Xrpl/Client/IXrplClient.cs:271, 887-891` +- Modify: потребители — по списку компилятора + +- [ ] **Step 1: Переименовать** + +`Tx` → `TxV1` в интерфейсе и реализации. `request.ApiVersion = 1` остаётся — теперь это соответствует имени. + +XML-doc обоих методов должен объяснять разницу: `TxV1` отдаёт `TransactionResponse` с полями транзакции на верхнем уровне; `TxV2` отдаёт `TransactionSummary`, где `tx_json` и `meta` — соседи, как их присылает v2. И что выбор метода задаёт версию протокола независимо от `ClientOptions.ApiVersion`. + +- [ ] **Step 2: Починить потребителей** + +`grep` покажет. Внутри SDK вызовов `Tx()` нет — только тесты и, возможно, демо-проекты. + +- [ ] **Step 3: Приёмка и коммит** + +```bash +dotnet build XrplCSharp.sln -v q --nologo +``` +```bash +git commit -m "refactor(client)!: Tx переименован в TxV1 — версия протокола названа в имени" +``` + +--- + +### Task 5: `CHANGES.md` и финальная приёмка уровня + +- [ ] **Step 1: Раздел** + +Назвать: переименование `Tx` → `TxV1` с объяснением почему; новые поля; исправление подмены `DeliverMax`. Показать код «было → стало» для переименования. + +- [ ] **Step 2: Полная приёмка** + +```bash +dotnet build XrplCSharp.sln -v q --nologo +``` +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestU" +``` +```bash +docker compose -f .ci-config/docker-compose.ci.yml up -d +``` +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestI" +``` +```bash +docker compose -f .ci-config/docker-compose.ci.yml down +``` + +- [ ] **Step 3: Замер на живых данных** + +Прогон диффа по всем файлам в scratchpad. Ожидается: приписанных 0 везде; потерянных — только то, что осознанно осталось. + +--- + +## Что этот уровень сознательно не делает + +- **Не даёт стримам сырой текст.** `TransactionStream` идёт через `EnqueueStreamMessage(Text())` — там уже UTF-16 строка, кадра нет. Перевести стримы на байты значит повторить уровень 0 для второго пути; объём несопоставим с остальным содержимым уровня. Остаётся как названный остаток. +- **Не чинит потерю полей у нераспознанного `LedgerEntryType`.** Объект, чей тип не входит в switch `GetTypeForLedgerEntry`, десериализуется в голый `BaseLedgerEntry` и теряет всё, кроме трёх свойств. Это ровно тот сценарий, который закрывает `[JsonExtensionData]` из уровня 2 — если та задача завершится исходом A, вопрос снимается; если нет, он остаётся открытым и его место в уровне 4. +- **Не трогает `TransactionResponse.TransactionType`**, оставленный non-nullable в уровне 2: это дискриминатор на пути подписи. diff --git a/plans/2026-08-17-raw-response-level4.md b/plans/2026-08-17-raw-response-level4.md new file mode 100644 index 00000000..035ead27 --- /dev/null +++ b/plans/2026-08-17-raw-response-level4.md @@ -0,0 +1,190 @@ +# Raw Response, уровень 4: точность под охраной CI + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Сделать так, чтобы достигнутая точность не деградировала молча — ни от нового амендмента, ни от правки модели. + +**Architecture:** Уровни 0–3 убрали приписки, потери и подмены. Все замеры до сих пор делались вручную, временными консольными проектами, которые удалялись после. Значит завтра любой из дефектов может вернуться, и никто не заметит — ровно так `LOAccountRoot` жил без `WalletLocator`/`WalletSize` до ручной ревизии, а `sfLEVersion` пришлось ловить уведомлением protocol-watch вместо красного теста. Уровень 4 превращает разовый замер в тест. + +**Tech Stack:** .NET 8/9/10, System.Text.Json, MSTest 4.0.2, GitHub Actions. + +--- + +## Установлено при приёмке уровня 3: интеграционный стенд не терпит двух прогонов сразу + +Приёмка уровня 3 дала 10, 2 и 52 падения на трёх прогонах — разные наборы тестов, одна причина `tefPAST_SEQ` в `FundFromMasterAsync`. Выглядело как регресс. + +Локализация бинарным поиском по восьми точкам (`dev`, конец уровня 0, конец уровня 1, конец уровня 2, и каждый код-коммит уровня 3 по отдельности, затем HEAD) дала **265/265 на каждой** — при условии, что стенд используется эксклюзивно. + +Причина падений: два прогона `TestI` шли одновременно на одном standalone-узле. Мастер-аккаунт там один, `StandaloneLock.MasterFunding` сериализует funding **внутри процесса**, но не между процессами — второй прогон берёт тот же sequence и получает `tefPAST_SEQ`. + +Практическое следствие, которое стоит помнить: **`TestI` нельзя запускать параллельно с другим прогоном `TestI`**, и «падения на стенде» надо сначала проверять на эксклюзивность, а уже потом искать регресс в коде. В CI это не проявляется — там job `integration` один. + +## Что уже есть — использовать, не изобретать + +Разведка установила: + +- **Корпуса живых ответов нет.** Реальные ответы попадают в тесты обрезанными inline-константами прямо в `.cs` (например `Tests/Xrpl.Tests/Models/TestUAccountTransactionsEnvelope.cs:21-80` с комментарием «trimmed captures of real testnet responses»). Каталога, загрузчика и формата хранения не существует. +- **Vendored протокольные файлы есть**: `Tests/Xrpl.Tests/Fixtures/{ledger_entries,transactions}.macro` + `LedgerFormats.h`, с парсерами `RippledLedgerEntryFormats`, `RippledTransactionFormats`, `RippledLedgerFlags`. Это эталон **формата полей**, а не ответов. +- **Три conformance-теста уже работают по этому эталону**: `TestULedgerEntryFieldsConformance`, `TestUTxFormatConformance`, `TestULedgerFlagsConformance`, и добавленный уровнем 2 `TestUNullabilityConformance`. +- **Стенд для интеграционных тестов** — `.ci-config/docker-compose.ci.yml`, поднимается job'ом `integration` в `dotnet.test.yml`, ждёт активации AMM-амендмента как сентинела. +- **`GRequest` умеет отдавать `JsonNode`** вместо конкретной модели — `AmendmentGuard.cs:46` уже так делает. Это готовый строительный блок: «получить сырое и типизированное из одного ответа». +- **Пять еженедельных watch-workflow**: `protocol-watch`, `definitions-watch`, `release-watch`, `nightly-pin-watch`, плюс `docs` и `nuget.release`. + +--- + +## Решения + +**1. Корпус — файлы в репозитории, а не запись на лету.** Тест, ходящий в сеть, нестабилен и не воспроизводим. Корпус — набор `.json` в `Tests/Xrpl.Tests/Fixtures/Responses/`, каждый файл это целый ответ узла, снятый с mainnet, с фиксированным содержимым. Такие файлы уже есть у меня: они лежат в scratchpad и использовались для всех замеров. + +**2. Проверка — тот же дифф, что я гонял вручную.** Десериализовать `result` в модель, сериализовать обратно, структурно сравнить с исходником. Приписанных членов — ноль. Потерянные — сверяются со **списком известных и объяснённых**, каждый с причиной; всё, чего в списке нет, роняет тест. + +Список исключений критичен: он превращает «мы знаем, что теряем X» из устного знания в проверяемый факт. Если поле уходит из списка — тест краснеет и заставляет объяснить. + +**3. Живой узел — отдельным тестом, не заменой корпусу.** Корпус ловит регрессию модели, живой узел ловит новое поле от узла, которого корпус не знает. Это разные задачи, и второе идёт в интеграционные (`TestI`), которые уже гоняются на стенде. + +**4. Никакого нового workflow не заводим.** `dotnet.test.yml` уже гоняет `TestU` и `TestI` на каждый push и в merge queue. Fidelity-тесты — обычные тесты, они попадают туда сами. Заводить шестой watch-workflow ради того, что и так проверяется на каждом коммите, — лишняя сущность. + +--- + +### Task 1: Корпус ответов + +**Files:** +- Create: `Tests/Xrpl.Tests/Fixtures/Responses/*.json` +- Modify: `Tests/Xrpl.Tests/Xrpl.Tests.csproj` — копирование в вывод + +- [ ] **Step 1: Перенести снятые ответы** + +Файлы лежат в `Tests/Xrpl.Tests/Fixtures/Responses/`: + +| Файл | Что это | +|---|---| +| `tx_raw.json` | `tx`, API v2, Payment с `DeliverMax`, метаданные с `PreviousFields` | +| `tx_binary_raw.json` | `tx`, API v2, `binary: true` — `meta_blob` и `tx_blob` | +| `account_tx_raw.json` | `account_tx`, 10 транзакций, 36 KB — самый содержательный | +| `account_info_raw.json` | `account_info` | +| `account_objects_raw.json` | `account_objects`, с `warning: "load"` | +| `ledger_raw.json` | `ledger` | + +Скопируй в `Tests/Xrpl.Tests/Fixtures/Responses/`, сохранив имена. Это **живые ответы mainnet**, не синтетика — в этом их ценность. + +Рядом положи `README.md`: когда сняты, с какого узла, какой командой, почему именно эти. Без этого через год никто не поймёт, можно ли их обновлять. + +- [ ] **Step 2: Копирование в вывод** + +Посмотри, как в `Xrpl.Tests.csproj` уже настроено копирование `Fixtures/*.macro`, и сделай так же. Проверь, что файлы оказываются рядом со сборкой. + +- [ ] **Step 3: Коммит** + +```bash +git commit -m "test(fixtures): корпус живых ответов mainnet для проверки точности" +``` + +--- + +### Task 2: Тест точности + +**Files:** +- Create: `Tests/Xrpl.Tests/Models/TestUResponseFidelity.cs` + +- [ ] **Step 1: Написать тест** + +Для каждого файла корпуса: взять `result`, десериализовать в модель, сериализовать обратно, структурно сравнить. + +Правила: +- **Приписанных членов — строго ноль.** Никаких исключений: приписка это всегда ложь. +- **Потерянные** — сверяются с явным списком исключений, где у каждого записана причина. Всё прочее роняет тест. + +Список исключений на момент написания (проверь актуальность прогоном, числа могли измениться): + +| Файл | Поле | Причина | +|---|---|---| +| все | `$.status` | Живёт вне `result`; доходит через `XrplResponse.Status`. В диффе модели его быть и не должно | +| `account_objects_raw.json` | `$.warning` | Внутри `result` у этого метода; найдено уровнем 3, не закрыто | + +Маппинг «файл → модель» задай явно, и **падай на файле, для которого маппинг не задан** — иначе добавленный в корпус ответ молча не будет проверяться. + +Сообщение об ошибке должно быть таким, чтобы по нему сразу было видно, что сломалось: путь члена, приписан он или потерян, и — для потерянных — что его нет в списке известных. + +- [ ] **Step 2: Проверить, что тест ловит регресс** + +Не просто «зелёный». Убедись, что он краснеет: +- временно сделай любое nullable-свойство ledger-модели non-nullable → тест обязан упасть на приписке; +- временно убери `[JsonExtensionData]` с `BaseLedgerEntry` → тест обязан упасть на потере. + +Верни обе правки. **Это главная проверка задачи**: тест, который не краснеет на внесённом дефекте, бесполезен. + +- [ ] **Step 3: Коммит** + +```bash +git commit -m "test(models): точность round-trip под охраной теста, а не ручного замера" +``` + +--- + +### Task 3: Живой узел + +**Files:** +- Create: `Tests/Xrpl.Tests/Integration/TestIResponseFidelity.cs` + +- [ ] **Step 1: Тест против стенда** + +Корпус статичен и не узнает о поле, которое узел начнёт присылать завтра. Этот тест закрывает пробел: запросить у живого узла несколько ответов, получить **и** сырой JSON, **и** типизированную модель, сравнить. + +`GRequest` даёт сырое (`AmendmentGuard.cs:46` — образец), обычный типизированный вызов даёт модель. Либо проще: `XrplResponse` уже несёт оба — `Raw` и `Result`. + +Достаточно нескольких методов: `account_info`, `account_objects`, `ledger`, `tx` по транзакции, созданной тестом. + +Приписанных — ноль. Потерянные — логировать, но **не ронять тест**: на стенде rippled может отдавать поля, которых нет на mainnet, и падение здесь заблокирует CI по причине, не связанной с правкой. Задокументируй это решение в комментарии — почему приписки роняют, а потери только логируются. + +- [ ] **Step 2: Прогон на стенде** + +```bash +docker compose -f .ci-config/docker-compose.ci.yml up -d +``` +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestIResponseFidelity" +``` +```bash +docker compose -f .ci-config/docker-compose.ci.yml down +``` + +- [ ] **Step 3: Коммит** + +```bash +git commit -m "test(integration): проверка точности против живого узла" +``` + +--- + +### Task 4: Документация и финальная приёмка + +- [ ] **Step 1: `CHANGES.md`** + +Короткий раздел: что теперь под охраной и чего эта охрана не покрывает (стримы, о которых сказано ниже). + +- [ ] **Step 2: Полная приёмка** + +```bash +dotnet build XrplCSharp.sln -v q --nologo +``` +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestU" +``` +```bash +docker compose -f .ci-config/docker-compose.ci.yml up -d +``` +```bash +dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestI" +``` +```bash +docker compose -f .ci-config/docker-compose.ci.yml down +``` + +--- + +## Что этот уровень сознательно не делает + +- **Не заводит новый workflow.** `dotnet.test.yml` гоняет `TestU` и `TestI` на каждый push и в merge queue; fidelity-тесты попадают туда сами. +- **Не покрывает стримы.** `TransactionStream` не имеет `Raw`: путь стримов идёт через `EnqueueStreamMessage(Text())`, где уже UTF-16 строка и кадра нет. Сравнивать не с чем. Перевести стримы на байты — это повторить уровень 0 для второго пути; названо остатком в уровне 3 и остаётся им. +- **Не обновляет корпус автоматически.** Живые ответы фиксированы намеренно: тест должен ловить изменение модели, а не дрейф mainnet. Обновление корпуса — осознанное действие с обновлением списка исключений. diff --git a/specs/2026-08-17-preserve-raw-node-json.md b/specs/2026-08-17-preserve-raw-node-json.md new file mode 100644 index 00000000..2486f7c2 --- /dev/null +++ b/specs/2026-08-17-preserve-raw-node-json.md @@ -0,0 +1,269 @@ +# Сохранение исходного JSON ответа узла (raw response) + +Дата: 2026-08-17. Статус: все пять уровней сданы, PR #102 открыт в dev. + +> **Принятое решение (2026-08-17).** Breaking changes разрешены. Выбран вариант A в +> расширенной форме — **fidelity-first**: источник истины это исходный JSON узла, +> типизированная модель — проекция поверх него. Доставка через обёртку +> `XrplResponse` (`Result` + `Raw` + `ApiVersion` + `Warnings`), одним мажором, +> пятью планами по подсистемам: +> +> | Уровень | План | Суть | +> |---|---|---| +> | 0 | [level0](../plans/2026-08-17-raw-response-level0.md) | **сдан.** Один парс вместо двух, срез кадра вместо `JsonElement`; починен `HasNextPage`, возвращавший false на любом ответе | +> | 1 | [level1](../plans/2026-08-17-raw-response-level1.md) | **сдан.** `XrplResponse`: сырые байты и конверт наружу, 43 сигнатуры | +> | 2 | [level2](../plans/2026-08-17-raw-response-level2.md) | **сдан.** Nullability по протоколу; `[JsonExtensionData]`; `Id`/`Request` конверта на срезы | +> | 3 | [level3](../plans/2026-08-17-raw-response-level3.md) | **сдан.** Развод v1/v2, `Tx` → `TxV1`, непокрытые поля | +> | 4 | [level4](../plans/2026-08-17-raw-response-level4.md) | **сдан.** Корпус живых ответов и тест точности в CI | +> +> **Замеренный результат на тех же живых ответах mainnet, с которых начиналось исследование:** +> приписанных членов в `account_tx` было **156, стало 0**. Все приписки исчезли, включая +> подмену `Amount`/`DeliverMax`, закрытую уровнем 3. +> +> **Оценки объёма, которые оказались неверны** (полезно для будущих спек): «~115 свойств» из §4 +> считало все non-nullable свойства подряд. Измерение по vendored `ledger_entries.macro` дало +> **9** прямых нарушений протокола и **80** пар «модель × свойство», рискующих в метаданных, — +> разные величины, обе верные. Требуемость поля описывает ledger-объект, но те же модели служат +> содержимым `PreviousFields`, где отсутствовать может и обязательное поле. +> +> **Дефект, найденный уже после написания спеки:** `DeletedNode.FinalFields.Flags` (§2) закрыт +> уровнем 2 и перепроверен прогоном — фиксировался на состоянии до nullability. +> +> Отвергнут как самостоятельное решение вариант C (только nullability): лечит лишь +> один из трёх классов дефектов, см. §4. Отвергнут полностью lazy-слой моделей +> (чтение из `JsonElement` по требованию, как в xrpl.js): та же точность достигается +> гибридом дешевле — `JsonElement` стоит 1.8× от полезной нагрузки. + +> **Политика разрыва (2026-08-17).** Переходных мостиков нет: всё, что уходит, +> удаляется прямо к релизу. `[Obsolete]`-обёртки, дублирующие перегрузки «как было» +> и алиасы старых имён не создаются ни на одном из уровней — в частности, на уровне 1 +> старые сигнатуры 40 методов клиента **удаляются**, а не оборачиваются над +> `XrplResponse.Result`. +> +> Основание: в `Xrpl/` сейчас нет ни одного `[Obsolete]` — репозиторий никогда не нёс +> совместимостного слоя, и заводить его в мажоре, который и объявлен как разрыв, +> значит оставить два способа сделать одно и то же и отложить настоящую миграцию. +> Потребителям даётся `CHANGES.md` с таблицей «было → стало», а не молчаливо +> работающий старый путь. +> +> Практическое следствие для планов: раздел «Breaking» каждого плана обязан +> **перечислять удаляемые члены поимённо**, иначе исполнитель не отличит «убрать» от +> «оставить рядом». + +Потребитель-инициатор: StaticBit Wallet, экран сверки JSON транзакции и метаданных. + +## 1. Где в конвейере живёт исходный текст и до какого момента + +Живая цепочка (подключён только бинарный колбэк — `connection.cs:1067` вызывает +`ws.OnBinaryMessage(...)`; `_onMessageString` остаётся null): + +| Шаг | Что в руках | Форма | +|---|---|---| +| `WebSocketClient.ReceiveLoop` (`:517`/`:534`) | `completeMessage = new byte[result.Count]` | точный по размеру, **не** пулированный UTF-8 массив всего кадра | +| `CallOnMessage` (`:684`) | тот же массив | передаётся как есть | +| `connection.IOnMessageFastPath(byte[])` (`:3173`) | `utf8Message` | текст материализуется в UTF-16 только по требованию, через локальную `Text()` | +| `RequestManager.HandleResponse(ReadOnlySpan)` (`:514`) | `ErrorResponse` | `BaseResponse.Result` типизирован `object`, System.Text.Json кладёт туда **самодостаточный `JsonElement`** | +| `RequestManager.Resolve` → `DeserializeResult` (`:117`) | `JsonElement` | `element.Deserialize(type, options)` — исходный узел отбрасывается сразу после | + +Установлено прогоном: `BaseResponse.Result` действительно `System.Text.Json.JsonElement` +(проверено на всех пяти методах). Значит **точный исходный текст поддерева `result` +доступен бесплатно в момент `Resolve`** — `resultElement.GetRawText()` возвращает +оригинальный текст с сохранением порядка ключей. Дальше `Resolve` его теряет. + +Итог: исходный текст умирает в двух местах — байтовый кадр после возврата из +`IOnMessageFastPath`, `JsonElement` после `DeserializeResult`. Ни то, ни другое +никуда не удерживается. + +## 2. Что теряется и приписывается на реальных ответах (замеры) + +Стенд воспроизводит конвейер один в один: `Deserialize` → `(JsonElement)Result` +→ `.Deserialize(type, XrplJsonOptions.Default)` → `Serialize` → структурный дифф. +Живые ответы mainnet, `api_version = 2`. + +### tx → `TransactionResponse` (то, что возвращает `IXrplClient.Tx`) + +- 2616 B на входе → 1595 B после round-trip. +- **ПОТЕРЯНО:** `tx_json` целиком (в v2 транзакция вложена, модель ждёт её плоско), + `ledger_hash`, `ctid`, `close_time_iso`, `status`. +- **ПРИПИСАНО:** `TransactionType = "AccountSet"` — дефолт enum. Реконструкция + **называет Payment-транзакцию AccountSet**. Плюс в `meta`: `PreviousFields.Flags = 0`, + `PreviousFields.PreviousTxnLgrSeq = 0`, `PreviousFields.OwnerCount = 0`, + `PreviousFields.LedgerEntryType`, и то же самое в `FinalFields`. + +`TransactionResponse` — v1-модель. Для v2 верна `TransactionSummary` (метод `TxV2`). + +### tx → `TransactionSummary` (правильная v2-модель) + +- **ПОТЕРЯНО:** `close_time_iso`, `ctid`, `status`, и `tx_json.DeliverMax`. +- **ПРИПИСАНО:** `tx_json.Amount` вместо `DeliverMax` (в API v2 узел переименовывает + `Amount` → `DeliverMax`; модель пишет обратно v1-имя), плюс те же 8 приписок в `meta`. + +То есть даже на правильной модели экран сверки покажет поле с именем, которого узел +не присылал, — и не покажет то, которое присылал. + +### account_tx → `AccountTransactions` (10 транзакций, 36 KB) + +- 36677 B → 39646 B (**вырос на 8 %** за счёт приписанных полей). +- **156 приписанных полей**, из них: `PreviousFields.Flags = 0` ×24, + `FinalFields.PreviousTxnLgrSeq = 0` ×20, `PreviousFields.PreviousTxnLgrSeq = 0` ×20, + `FinalFields.LedgerEntryType` ×20 (DirectoryNode) + ×14 (AccountRoot) + ×6 (RippleState), + `PreviousFields.OwnerCount = 0` ×8, `PreviousFields.Sequence = 0` ×4, + `CreatedNode.NewFields.PreviousTxnLgrSeq = 0` ×3. +- **28 потерянных**, в том числе `DeletedNode.FinalFields.Flags = 0` ×3 — то же поле + `Flags`, которое в других узлах приписывается нулём, здесь **теряется, хотя узел + прислал ровно ноль**. Асимметрия в обе стороны на одном и том же поле. +- Также потеряно на каждой записи: `close_time_iso`, `tx_json.ctid`, `tx_json.DeliverMax`. + +### ledger → `LOLedger` + +- **ПОТЕРЯНО:** `ledger.close_time_iso`, `status`. +- **ИСКАЖЕНО:** `ledger.ledger_index: 106359162 → "106359162"` (число стало строкой). + +### account_objects → `AccountObjects` + +- Потеряно `status` и `warning: "load"` — то есть предупреждение узла о приближении + к rate-limit не доживает до типизированного ответа. + +### account_info → `AccountInfo` + +- **ПРИПИСАНО:** `ledger_index = 0`, которого узел не присылал. + +### Вывод по п.2 + +Проблема **не ограничена метаданными**. Три независимых класса дефектов: + +1. **Дефолты non-nullable CLR-свойств** (`Flags`, `OwnerCount`, `Sequence`, + `PreviousTxnLgrSeq`, `LedgerEntryType`, `TransactionType`, `ledger_index`) — + приписываются как данные леджера. +2. **Отсутствующие в моделях поля** (`close_time_iso`, `ctid`, `meta_blob`, `tx_blob`, + `warning`, `status`) — исчезают молча. Это же произойдёт с любым полем нового + амендмента. +3. **Расхождение имён v1/v2** (`Amount` ↔ `DeliverMax`) и типов (число ↔ строка) — + round-trip не просто теряет, а **подменяет** содержимое. + +Пункт 3 отдельно опасен: подпись «(reconstructed)» предупреждает о неточности, но +не о том, что имя поля подменено на другое валидное имя протокола. + +## 3. Проверка MetaBinaryConverter + +`Read()` имеет три ветки: null, строка → `Meta { MetaBlob = ... }`, объект → обычная +десериализация. `Write()` снимает себя из опций и пишет объект. + +Замерено на живых ответах: + +- **API v1, `binary: true`** — узел присылает `"meta": ""`. Строковая ветка `Read()` + срабатывает, `MetaBlob` заполняется. +- **API v2, `binary: true`** — узел присылает `meta_blob` и `tx_blob` **отдельными + полями верхнего уровня**, поля `meta` в ответе нет вообще. Строковая ветка `Read()` + не срабатывает никогда. Прогон: `TransactionSummary` теряет `meta_blob` и `tx_blob` + целиком, 2246 B → 195 B. + +Следствия: + +- Строковая ветка `Read()` — **v1-only**. Кошелёк, форсирующий v2, её не задействует. +- `Write()` уже сегодня асимметричен `Read()`: прочитанный из строки `Meta` он выведет + как `{"MetaBlob":"..."}` — форма, которой нет ни в одной версии API. То есть на + round-trip он и так не рассчитан, и никакой существующий сценарий на нём не держится. +- **Решение через сырой текст `MetaBinaryConverter` не трогает**: оно проходит мимо + сериализатора. Это, наоборот, единственный способ показать `meta_blob`/`tx_blob`, + которые сейчас не доходят вообще. + +## 4. Варианты + +### A. Отдавать сырой текст рядом с типизированным ответом — рекомендуется + +Точное решение задачи: показывается ровно то, что прислал узел, включая поля новых +амендментов и `meta_blob`. Опирается на факт из п.1 — исходный текст уже в руках, +дополнительного разбора не нужно. + +Замеры удержания (account_tx, 36691 B на проводе, 36677 B поддерево `result`): + +| Представление | Байт на ответ | +|---|---| +| `JsonElement` (**то, что конвейер строит уже сейчас**) | 65 369 | +| `GetRawText()` — строка UTF-16 | 73 385 | +| **`result` как UTF-8 `byte[]`** | **36 713** | +| весь кадр как `byte[]` (уже выделен сокетом) | 36 729 | + +`JsonElement` дороже полезной нагрузки в 1.8× — `JsonDocument.ParseValue` арендует +массив из `ArrayPool` (65 536 для сообщения в 36 691 B) и не возвращает его. +Значит **отдача сырых UTF-8 байт дешевле по памяти, чем промежуточный объект, +который конвейер и так создаёт**. UTF-16 строку хранить не нужно — материализовать +по требованию. + +Стоимость: опциональность обязательна (по умолчанию выключено — ноль накладных +расходов), нужен канал доставки, не ломающий сигнатуры. Два кандидата: + +- `ConditionalWeakTable` + `client.TryGetRawResponse(typed, out …)` — + ноль правок в существующих сигнатурах, время жизни привязано к типизированному + объекту и освобождается GC автоматически (для мобильного клиента это то, что нужно: + сырой текст жив ровно пока открыт экран). +- Явная перегрузка `RequestRaw(…)` → `(T Typed, ReadOnlyMemory Raw)` — + предсказуемее по времени жизни, но требует новых методов на поверхности API. + +### B. `[JsonExtensionData]` для неизвестных полей + +Ловит `close_time_iso`, `ctid`, `meta_blob`, поля будущих амендментов. Но **не решает +задачу**: приписанные нули non-nullable свойств остаются, подмена `Amount`/`DeliverMax` +остаётся (это не неизвестное поле — оно известно под другим именем), порядок ключей +и точная форма чисел не восстанавливаются. Плюс `Dictionary` +на каждый узел метаданных — по памяти это худший вариант из трёх, и он не опционален. + +Как дополнение к A имеет смысл; как замена — нет. + +### C. Сделать поля nullable + +Убирает класс дефектов №1 и ничего больше. Класс №2 (неизвестные поля) и №3 +(подмена имён, число↔строка) остаются — а именно они дают самые опасные для экрана +сверки искажения (`TransactionType = "AccountSet"` этим лечится, `Amount` вместо +`DeliverMax` — нет). + +Цена: ~115 non-nullable value-свойств в `Models/Ledger` (64) и `Models/Transactions` +(51). Смена `uint` → `uint?` — **ломающее изменение публичного API** для каждого +потребителя, читающего `.Flags`, `.Sequence`, `.OwnerCount`. При явном ограничении +«публичный API ломать нежелательно» это самый дорогой вариант с наименьшим эффектом. + +Отдельно: даже при полной nullability реконструкция принципиально не может быть +точной — она восстанавливает не текст узла, а проекцию текста на модель. + +### Рекомендация + +**A как решение, C как отдельная гигиеническая правка на будущий мажор.** + +A закрывает задачу целиком и стоит дешевле по памяти, чем уже существующий +промежуточный `JsonElement`. C стоит сделать не ради экрана сверки, а потому что +приписанные нули врут и в обычном коде — но это ломающее изменение, ему место в +отдельном релизе, и на A оно не влияет. + +B — опционально сверху A, если понадобится типизированный доступ к неизвестным +полям без разбора сырого текста. Для текущей задачи не нужен. + +## 5. Ограничения, которые решение обязано соблюсти + +> **Записано до решения о мажорном разрыве и частично им отменено.** Требование +> «по умолчанию выключено, публичный API не ломать» действовало, пока обсуждался +> вариант с опциональным сырым текстом рядом со старой сигнатурой. После того как +> breaking changes были разрешены, выбран `XrplResponse`: сырой текст приходит +> всегда, потому что он ничего не стоит — кадр всё равно выделен сокетом, и +> удержание окна на него дешевле промежуточного `JsonElement`, который был раньше. +> +> Остальные три пункта в силе и соблюдены: байты хранятся как UTF-8, срез идёт до +> `result`, время жизни привязано к ответу. + +- ~~По умолчанию выключено; при выключенном режиме — ни одной лишней аллокации.~~ + Отменено: сырой текст отдаётся всегда, см. врезку выше. +- Хранить UTF-8 байты, не UTF-16 строку; текст материализовать по требованию. +- Резать до поддерева `result` (36677 из 36691 B — разница мала, но срез убирает + конверт с `id` и служебными полями). +- Время жизни привязать к типизированному ответу, а не к клиенту, иначе на + постраничном обходе `account_tx` сырые тексты будут копиться. + +## 6. Побочные находки (вне заявленной задачи) + +- `IXrplClient.Tx` возвращает v1-модель `TransactionResponse`. На `api_version = 2` + она теряет `tx_json` целиком и подставляет `TransactionType = "AccountSet"`. + Кошельку на v2 следует звать `TxV2`; но сам факт, что `Tx` на v2 отдаёт заведомо + неверный `TransactionType`, — отдельный баг, стоящий заявки. +- `meta_blob` / `tx_blob` (v2, `binary: true`) не смоделированы вообще — ответ + сжимается с 2246 B до 195 B. +- `warning: "load"` не доходит до типизированного ответа `account_objects`.