Release 11.0.0.0 - #142
Merged
Merged
Conversation
…и терять (#102) * feat(client): записывать границы result в кадре вместо материализации JsonElement * refactor(client): перенести тесты JsonSliceConverter по конвенции и закрепить контракт буфера * feat(client): RawJson — окно на исходные байты ответа без копии * fix(client): WriteTo не падает на пустом окне; RawJson проверяет границы и определяет равенство * test(client): закрепить контракт равенства RawJson; ArgumentOutOfRangeException называет виновный аргумент * perf(client)!: разбирать result один раз из кадра, конверт хранит границы вместо JsonElement * refactor(client): упростить скан marker, вернуть BOM и закрепить владение кадром * test(client): бюджет типизированного пути и удержания конверта; CHANGES для уровня 0 * docs: разбор проблемы сырого JSON ответа и план уровня 0 * docs(changes): код миграции с Result, сдвиги поведения; тест удержания вне параллельного прогона * docs(plan): перенести в уровень 1 замечания финального ревью * docs(plan): план уровня 1 — XrplResponse<T> * feat(client): RawJson умеет разбор и скан верхнего уровня; кадр привязывается одним AttachFrame * chore(utils): убрать мёртвый using Xrpl.Client.Json из Index.cs * docs(client): восстановить смысл комментария про экранированные ключи * refactor(client): убрать лишнюю копию в ToJsonElement, починить ссылку в доке и усилить тесты * feat(client)!: методы возвращают XrplResponse<T> с сырым JSON и конвертом ответа * docs(plan): фактический объём правок в тестах и ловушка с маскирующим моком * refactor(client)!: Connection сам собирает XrplResponse; конверт несёт warning * test: перевести вызовы клиента на XrplResponse<T> Тестовый проект приведён в соответствие с XrplResponse<T>: все вызовы методов IXrplClient распакованы через .Result в месте использования, без ослабления существующих проверок. FeeTestClient (единственный мок IXrplClient в проекте) реализует новые сигнатуры интерфейса. ResolvedResponse и XrplResponse.From<T> сделаны публичными: Promise в RequestManager.XrplRequest/XrplGRequest публичный и резолвится в ResolvedResponse, поэтому внешний потребитель, работающий с RequestManager напрямую (в обход методов клиента), должен иметь возможность распаковать результат тем же способом, каким это делает сам Connection. * test(client): сквозной тест — сырой JSON и конверт доходят от сокета до вызывающего * docs(changes): уровень 1 — XrplResponse<T>, миграция вызовов и конверт ответа * fix(clients)!: мигрировать демо-проекты на XrplResponse; Deconstruct и HasNextPage на обёртке Test.ClonsoleApp и Blazor-WebAssembly — потребители, которых не заметили при переводе тестового проекта на XrplResponse<T>; XrplCSharp.sln (то, что собирает CI) их включает, и голый dotnet build падал. Правки те же, что и в тестах: .Result в месте использования, без изменения поведения. XrplResponse<T> получил Deconstruct(out T, out RawJson) и HasNextPage — var (info, raw) = await client.AccountInfo(request) и постраничный обход account_tx снова работают без .Result. XrplResponse.From<T> получил перегрузку от ResolvedResponse: несовпадение типа теперь ловится компилятором, а не XrplException в рантайме. CHANGES.md и план уровня 1 приведены в соответствие с кодом: вводный абзац описывает обе половины работы, размер структуры (56 байт для ссылочного T), предупреждение про Tx()/api_version=1, и то, что сахарные методы и Request/GRequest не меняли сигнатуру асинхронности так же, как остальное. * docs(plan): план уровня 2 — nullability по протоколу и неизвестные поля * docs(plan): исправить Task 3 — маппинга тип-транзакции на модель не существует * test(models): тест соответствия nullability — сейчас красный, фиксирует объём * docs(plan): решения по LedgerEntryType и типу PreviousTxnLgrSeq в NFTokenPage * fix(models)!: ledger-модели больше не приписывают нули отсутствующим полям * fix(sugar)!: отсутствующие Flags не открывают доступ; LedgerEntryType не приписывается * fix(models)!: модели транзакций и ответов не приписывают значений * perf(models)!: Id и Request конверта хранят границы вместо JsonElement BaseResponse.Id и ErrorResponse.Request были типизированы object, из-за чего System.Text.Json строил под них JsonElement с невозвращаемой арендой из ArrayPool на каждом ответе (и на каждом опросе неподтверждённой транзакции в Sugar/Submit.cs через RippledException). Переведены на тот же механизм срезов, что и result: IdSlice/RawId на BaseResponse, RequestSlice/RawRequest на ErrorResponse. RequestManager.HandleResponse разбирает Guid прямо из байтов среза через Utf8Parser, без промежуточной строки. XrplErrorClassifier.ToJsonObjectSafe парсит RawRequest.Span напрямую вместо повторной сериализации. * docs(plan): план уровня 3 — развод API v1 и v2 * docs(spec): актуализировать статус уровней и замеренный результат * docs(spec): поправить статус в шапке * feat(models): неизвестные поля больше не теряются BaseLedgerEntry и BaseTransactionResponse получили [JsonExtensionData] (UnknownFields): поля, которых модель не знает — например ctid или поле нового амендмента — больше не отбрасываются молча при десериализации, а сохраняются как Dictionary<string, JsonElement> и переживают round-trip сериализации. Разведка прогоном подтвердила, что атрибут работает на всех путях: прямая десериализация LO*/*Response типов, через LOConverter / ModifiedNode/CreatedNode/DeletedNode (FinalFields/PreviousFields/ NewFields), через TransactionResponseConverter — все эти конвертеры разбирают только конверт (выбор конкретного .NET-типа), а разбор полей самого типа делегируют обычному reflection-based десериализатору, который и учитывает JsonExtensionData. TestULedgerEntryFieldsConformance обновлён: UnknownFields — не поле объекта леджера, а служебный приёмник, поэтому исключён из diff'а наравне с JsonIgnore-свойствами. * docs(changes): уровень 2 — nullability по протоколу и неизвестные поля * feat(models): close_time_iso, ctid, status и binary-поля API v2 больше не теряются * fix(models)!: Payment возвращает то имя суммы, под которым оно пришло PaymentResponse запоминает, пришло ли значение под именем DeliverMax (API v2) или Amount (API v1), и при обратной сериализации пишет то же имя обратно. Раньше модель всегда писала Amount — не потеря поля, а подмена на другое валидное имя протокола: узел прислал DeliverMax, округление ответа отдаёт Amount, которого узел не присылал. Payment (класс запроса, участвующий в подписи через ToJson() -> EncodeForSigning) поведение не меняет: DeliverMax не существует как поле бинарного кодека, и обратная запись под этим именем тихо роняла бы Amount из подписываемого блоба. Amount пишется всегда, как и раньше — асимметрия с PaymentResponse задокументирована в коде. * refactor(client)!: Tx переименован в TxV1 — версия протокола названа в имени Tx() жёстко ставил api_version=1, игнорируя ClientOptions.ApiVersion (по умолчанию 2) — единственное место в SDK, где выбор метода молча расходится с настройкой клиента. Уважать настройку он не может: TransactionResponse не имеет поля для tx_json, и v2-ответ потерял бы транзакцию целиком, а не просто имя поля. Раз поведение остаётся прежним, имя обязано его отражать: Tx -> TxV1, рядом остаётся TxV2. Потребители внутри SDK не пострадали — единственный внутренний вызов это TxV2 (Sugar/Submit.cs). Затронуты только тесты и фейковый IXrplClient в TestUAutofillFees.cs. * docs(plan): план уровня 4 — точность под охраной CI * docs(changes): уровень 3 — развод v1/v2 и непокрытые поля * docs(plan): зафиксировать, что TestI не терпит параллельных прогонов на одном стенде * test(fixtures): корпус живых ответов mainnet для проверки точности Шесть JSON-RPC ответов, снятых с https://xrplcluster.com (api_version: 2, HTTP JSON-RPC) 2026-08-17: tx, tx с binary:true, account_tx (10 транзакций), account_info, account_objects (с warning: load) и ledger. Используются TestUResponseFidelity для проверки, что округление модели туда-обратно не теряет и не приписывает членов дерева ответа. * test(models): точность round-trip под охраной теста, а не ручного замера TestUResponseFidelity десериализует result каждого файла корпуса (Fixtures/Responses) в модель через XrplJsonOptions.Default, сериализует обратно и структурно сравнивает с исходным деревом. Приписанные члены — строго ноль без исключений; потерянные сверяются с явным списком причин (KnownLostMembers), всё прочее роняет тест. Маппинг файл->модель заведён явно и падает на любом файле корпуса без записи в нём. Проверено двумя искусственными регрессиями (внесены и возвращены): снятие nullable с LORippleState.HighQualityIn даёт 24 приписки, снятие [JsonExtensionData] с BaseLedgerEntry — 3 потери на DeletedNode.FinalFields.Flags. * docs(changes): уровень 4 — точность под охраной теста * docs(changes): сводка миграции по осям; исправить утверждения, разошедшиеся с кодом * docs(readme): примеры под XrplResponse<T> и показ сырого JSON * docs(plan): убрать машинные пути из планов * test(models): внятный отказ, если рефлексия не найдёт поле маппинга * fix(models)!: PaymentResponse сохраняет оба имени суммы, когда узел прислал оба rippled на api_version: 1 присылает и Amount, и DeliverMax для одной транзакции. Флаг _amountReceivedAsDeliverMax был двоичным и помнил только последний сработавший сеттер, поэтому второе поле терялось при обратной сериализации. Заменён на два независимых флага присутствия (_receivedAsAmount, _receivedAsDeliverMax) — теперь оба имени пишутся обратно, если оба пришли от узла. Добавлена живая фикстура api_version: 1 (tx_v1_raw.json) в корпус TestUResponseFidelity и подключена как TransactionResponse (метод TxV1) — единственный файл в корпусе, покрывающий эту форму ответа. * refactor(client)!: границы среза — деталь реализации, а не публичный API * fix(models): конверт ответа снова сериализуется — границы среза set-only * docs(changes): исправить описания, разошедшиеся с кодом после правок * docs(readme): убрать недоступный OnConnected, дополнить using в примерах * docs(spec): актуализировать статус — все уровни сданы * docs(changes): назвать новые исключения sugar-методов * chore(coderabbit): временно исключить Tests из ревью — 166 файлов против лимита 150 * docs: пример миграции без ?? 0; отметить отменённые ограничения спеки * docs(plan): отметить неполноту инвентаря и случай двух имён суммы * fix(models)!: ServerState reserve_base/reserve_inc are nullable State.ValidatedLedger.ReserveBase/ReserveInc were non-nullable uint, so a server_state response missing either field silently read back as 0. Balances.GetXrpFreeBalance then computed the account/owner reserve as zero and returned a free balance inflated by the reserve it failed to subtract - the same class of defect the raw-response work in this release removes from the ledger and transaction models, just found on a Methods model instead, which is why TestUNullabilityConformance (built off ledger_entries.macro and the transaction formats) never saw it. Both properties become uint?, and GetXrpFreeBalance now throws ValidationException when either is absent, matching the existing OwnerCount check beside it. Also guards the AccountData null checks both GetXrpBalance and GetXrpFreeBalance were missing, and updates the one other consumer of these fields, the console demo client. * fix(client): four unguarded null derefs raise typed exceptions instead of NRE/InvalidCastException - RequestManager.HandleResponse(byte[]): a frame holding the bare JSON literal null deserializes to a null ErrorResponse rather than throwing, so the next line's AttachFrame call threw a bare NRE instead of a typed protocol error. Now throws XrplException. - BatchNormalizer.NormalizeBatchTransaction / Autofill.SetNextValidSequenceNumber: ai.AccountData / data.AccountData was dereferenced before the null check that already guards its Sequence, so a response without account_data threw NRE instead of the intended ValidationException/ XrplException. Checked ahead of the existing check, same message style. - XrplResponse.From(ResolvedResponse): the unchecked (T) cast threw a bare InvalidCastException on a type mismatch, unlike the sibling From<T>(object) overload a few lines up, which raises a typed XrplException for its own kind of mismatch. Both overloads now fail the same way, naming the expected and actual type. Also normalizes DateTime.Kind before FromStringDateTimeConverter.Write formats with "K": Unspecified emitted no zone marker at all (neither "Z" nor a numeric offset), and Local wrote a local offset that Read (always UTC) would not reproduce on the way back in. Unspecified is now treated as UTC, mirroring DateTimeStyles.AssumeUniversal on Read; Local is converted to UTC first. * docs: fix XML doc drift in IXrplClient, LODirectoryNode, LOTicket - IXrplClient.Ping/Fee/Random document a <param name="request"> that does not exist on their signature (all three take only a CancellationToken); removed. Checked every other <param name="request"> in the file against its signature, no other mismatches. - LODirectoryNode.Flags remark claimed the uint -> uint? change was backwards compatible; it is not (code reading Flags as a non-nullable uint stops compiling). Reworded: still a uint-based bit-mask, now nullable to express absence, marked breaking. - LOTicket: Account and TicketSequence docs carried sentences copy-pasted from LOCheck (the sender/balance debit description and the SendMax description). TicketSequence now describes what it actually is - the Sequence Number the Ticket sets aside - and the same copy-paste artifact is also cleaned off Account and Flags in the same file. * chore(coderabbit): второй проход — только Tests, библиотека уже отревьюена * fix(clients): проверять весь путь server_state до разыменования * test: устранить тесты, проходящие по неверной причине, и хрупкий замер удержания * test(client): замер удержания усиливает сигнал вместо фильтрации шума * fix(sugar): batch-подпись проверяет account_data и флаги до разыменования * chore(coderabbit): убрать временный фильтр — оба прохода по PR #102 сделаны * fix(models): preserve unknown fields on method result models AccountObjects, AccountInfo and AccountTransactions had no JsonExtensionData, so any member rippled sent that the model does not declare a property for was silently dropped on deserialize (e.g. warning inside account_objects results). They now inherit a shared BaseMethodResult carrying UnknownFields, mirroring the pattern already used for ledger entries and transaction responses. LOLedger already inherits LOBaseLedger for the ledger_hash/ledger_index fields it shares with ledger_closed/ledger_current, so it gets the extension-data property directly instead of a second base. TestUResponseFidelity.KnownLostMembers is updated to match: $.warning is gone for account_objects_raw.json since it no longer drops, and $.status is gone for account_info_raw.json/account_objects_raw.json/ account_tx_raw.json/ledger_raw.json for the same reason - verified by temporarily emptying those exception lists and confirming the fidelity test still passes. tx_raw.json/tx_binary_raw.json still lose $.status because they deserialize into TransactionSummary, which has no extension-data property of its own and was out of scope here. * test(ledger): guard unknown-type ledger entries against losing fields BaseLedgerEntry.UnknownFields (added earlier for known ledger entry types with an amendment field this SDK does not yet model) already covers the case where LOConverter falls back to bare BaseLedgerEntry for a LedgerEntryType this SDK does not recognize at all - verified by running these tests before any production code change: fields survive both a direct/converter deserialize and a ModifiedNode.FinalFields deserialize, and round-trip back out unchanged. There was no prior test pinning that behavior, so a future converter change could regress it silently; these tests close that gap without needing a fix. * feat(streams): carry the frame through the stream pipeline for Raw JSON access Connection.EnqueueStreamMessage queued a string with no frame behind it, so TransactionStream, LedgerStream and every other subscription event had no way to hand back the bytes a node actually sent - the one remainder the earlier raw-response work named and left standing across three levels. - Channel<string> -> Channel<byte[]>; ProcessStreamMessageAsync and friends take the frame. OnMessage(string) still builds one via Encoding.UTF8.GetBytes, same as RequestManager.HandleResponse(string); the binary path reuses the frame the socket produced instead of encoding a second copy. - BaseStream gains Raw and AttachFrame(byte[]), mirroring BaseResponse: the frame IS the event here, so Raw spans the whole of it via the new JsonSlice.OfDocument. - LedgerStream now extends BaseStream instead of declaring its own Type field (which, lacking [JsonInclude], was never actually set by deserialization - harmless since the type is always ledgerClosed, but not by design). - TransactionStream gains RawTransaction (tx_json under v2, transaction under v1). Both names are already claimed by the typed Transaction property and its v1 alias, so a converter-backed slice member is not an option - JsonSlice.FindTopLevelMember scans the frame directly instead. - OnMessage(null) still cannot throw out of the entry point; a null frame is guarded through to the OnError report exactly as the string path was. TestUStreamRawJson covers the pipeline end to end and measures attach cost at 0 B marginal per instance over 2000 samples (budget 300 B, frame is 744 B). * fix(models): nullable fields the node may omit, verified against rippled Nine response properties were non-nullable while rippled writes them conditionally, so an absent field deserialized to 0/false and was then fabricated back into the serialized output. Each verdict is anchored to the rippled source that emits the field: lookupLedger (RPCLedgerHelpers.cpp) makes ledger_index/ledger_current_index mutually exclusive, subLedger (NetworkOPs.cpp) gates the whole ledger block behind getValidatedLedger, pubValidation gates ledger_index on sfLedgerSequence, and the feature command never emits ledger_index at all. The remaining 28 candidates were confirmed unconditional and left as-is. * fix(models): six more fields the node omits, incl. fee_ref on every mainnet event Continues the rippled-anchored nullability pass onto fields the first sweep left behind, all on the ledger subscribe/push paths. fee_ref is the notable one: rippled guards it with `if (!rules().enabled(featureXRPFees))` on both pubLedger and subLedger, and XRPFees is active on mainnet - so no current node sends it, and every ledgerClosed event round-tripped by a consumer gained a fee_ref: 0 the node never wrote. subLedger gates ledger_time and fee_base behind the same getValidatedLedger() check as the already-fixed ledger_index/reserve_base/reserve_inc, and emits no txn_count on that path at all - which LedgerStreamResponse own summary already said while the property fabricated it anyway. ServerFeatures.Validated matches the LedgerIndex fix beside it: the feature handler writes neither field, and the converter collapsed absent into false. TestUEnvelopeModelsStaySerializable asserted on whichever member was still non-nullable; it now asserts on a value it sets, so correctly making another field nullable cannot break it again. * fix(json): match last occurrence of a duplicate top-level key, case-insensitively JsonSlice.FindTopLevelMember returned on the first match and stopped scanning, while System.Text.Json's own deserialization keeps the last value for a duplicate JSON member (the default unless AllowDuplicateProperties = false, which XrplJsonOptions.Default does not set). A frame with two top-level tx_json members left TransactionStream.RawTransaction pointing at the first occurrence while the deserializer-fed Transaction reflected the last - a wallet would show one transaction and sign a different one. The scan now continues to EndObject and keeps the last match. Both FindTopLevelMember and RawJson.HasTopLevelProperty also matched case-sensitively via Utf8JsonReader.ValueTextEquals, 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. Both now decode the property name through Utf8JsonReader.GetString() (which also unescapes it) and compare with StringComparison.OrdinalIgnoreCase, matching the deserializer. HasTopLevelProperty still returns on first match - presence does not depend on which occurrence is meant. Also replaces a tautological assert in TestTransactionStreamRawTransactionUsesTxJsonUnderApiV2 (AreNotEqual(Raw.ToString(), RawTransaction.ToString()) passes for any two differently-sized strings regardless of content) with a direct check that RawTransaction does not carry fields that belong only to the outer event. * fix(subscriptions): stop fabricating BaseStream.Type, fix stale AttachFrame docs LedgerStream()'s constructor stamped Type = ResponseStreamType.ledgerClosed unconditionally, so an instance built by hand (not through the deserializer) reported a type it was never given - the same shape LedgerEntryType's stamped constructors took earlier in this release. Separately, the non-nullable enum defaulted to UNKNOWN (0), which JsonSerializer.Serialize(new TransactionStream()) wrote back as the literal member type:UNKNOWN for an event with no type at all. BaseStream.Type is now ResponseStreamType?, and the constructor is gone: deserialization off a real message already populates Type correctly, the same as every other property on these classes. Verified stream dispatch is unaffected - Connection.ProcessStreamMessageAsync decides which typed class to build from BaseResponse.Type, an unrelated string property deserialized separately from the raw type member, not from BaseStream.Type. Also corrects two comments in BaseStream.cs that described a validation TransactionStream.AttachFrame does not perform (unlike ErrorResponse.AttachFrame, which does validate a slice against the frame) - the real reason _frame is internal is that TransactionStream.RawTransaction reads it directly. * test(client): exercise the production byte[] frame path, not just OnMessage(string) All existing TestUStreamRawJson tests drive OnMessage(string), where Frame() always synthesizes a fresh byte array via Encoding.UTF8.GetBytes. The utf8Message branch of Frame() - the one ws.OnBinaryMessage actually feeds in production, and the entire reason the stream pipeline moved onto bytes - was never exercised by any test. Connection.IOnMessageFastPath(byte[]) is now internal instead of private (InternalsVisibleTo to Xrpl.Tests was already declared) so a test can call the same overload the socket callback calls, with a frame the test owns. The new test asserts result._frame is reference-equal to the array passed in, proving the frame is retained rather than copied on this path specifically. * test(models): pin ServerFeatures.Validated as null-on-omission ServerFeaturesConverter already reads Validated with the nullable ternary shape (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 full 1083-test suite green; verified by making that exact mutation locally and confirming this new assertion is what turns red (reverted after confirming). Added to the existing Deserialize_ServerFeatures_LedgerIndexIsNull test, which already uses a fixture the feature command sends with no validated member at all. * docs(models): document BaseMethodResult.UnknownFields Public member with no <summary>, which produced CS1591 and would have shipped to DocFx with no description. Mirrors LOLedger.UnknownFields' doc style, and calls out the retention cost review measured for a single large unknown member (roughly 1.79x, 36 700 B to 65 704 B on a captured response) since the class remarks previously only stated that values had already gone through JSON parsing. * fix(subscriptions): stop dropping fields rippled sends on every stream push The fabrication audit was thorough; the mirror-image audit for losses was not. rippled writes several members unconditionally that no stream model declared, so they vanished between the socket and the caller: network_id - NetworkOPs.cpp pubLedger and subLedger, both paths ctid - NetworkOPs.cpp transJson, on every validated transaction account_history_tx_index / _boundary / _tx_first - account_history subscribe BaseStream now carries [JsonExtensionData], mirroring BaseLedgerEntry, BaseTransactionResponse and BaseMethodResult for their own families, so every stream type picks the capture up without repeating the attribute. LedgerStreamResponse declares its own because it descends from BaseResponse, whose id/result members are byte-range slices rather than parsed values. ctid gets a real property instead: a wallet asking "which transaction is this" needs it typed, the way Hash is, not fished out of a dictionary. TestLedgerClosedRawSurvivesTheStreamPipelineByteForByte used network_id as its example of a member the model has no place for, and asserted the re-serialized output did NOT contain it - pinning the loss as expected behavior. That assertion is now flipped to prove the field survives. * fix(subscriptions): resolve the tx envelope the same way the typed side does AttachFrame preferred tx_json unconditionally while the typed Transaction takes whichever envelope appears later in the document - its two setters both do `value ?? _transaction` and run in document order. With both envelopes in one frame the two views disagreed, so a wallet could display RawTransaction and sign what Transaction held: the same show-one/sign-another split already fixed for duplicate keys in JsonSlice.FindTopLevelMember. rippled never sends both (transJson moves transaction to tx_json under API v2 rather than adding it), but the frame crosses arbitrary infrastructure on the way here, so the two views must not be able to disagree at all. Proven by mutation: restoring the unconditional tx_json preference turns the new test red (1 of 11), reverting it green again. * fix(models): extend unknown-field capture to every response projection The first pass gave BaseMethodResult to three models - the ones the fidelity corpus happened to have fixtures for. That is a test artifact, not a protocol boundary: account_lines, account_offers, account_channels, fee, server_info, server_state, path_find and the rest kept dropping anything they did not declare, and TransactionSummary - what both `tx` and each account_tx entry deserialize into, the second most important shape for a wallet after the stream - was still documented as losing `status`. 45 response projections now derive from BaseMethodResult. Excluded, each for a reason rather than by omission: 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 that already capture through BaseStream. KnownLostMembers is now empty, which is the point: every member of every captured mainnet response survives the round trip. The table stays as the mechanism that keeps it so. Proven by mutation - removing [JsonExtensionData] from BaseMethodResult turns the corpus test red. * fix(ledger): capture unknown fields on ledger-side response projections too The previous commit covered Models/Methods. The same loss lived in Models/Ledger, where BaseLedgerEntry's capture serves ledger *entries* and nothing served the response shapes beside them: LOLedgerData, LedgerEntity, LedgerEntryResponse, LOLedgerCurrentIndex, QueuedTransaction and the nested structures inside LOAmm/LODepositPreauth/LOOffer/LOVault. 17 types gained the capture directly rather than through a shared base - BaseLedgerEntry is about ledger entries, and giving a response shape that base to borrow one property would misstate what it is. TestURawSurvivesTheTripFromTheSocket asserted the typed projection *cannot* carry a member the model has no property for. That was true and is now the opposite: it proves the member reaches the caller on both sides, with Raw still the only byte-exact one. Its class doc said the same thing and is corrected with it. * fix(models): keep unknown-field capture off the outgoing path Review found the exclusion list from the previous commit was built by name, so four shapes that are read off a response and fed back into an outgoing one kept the capture: Methods.Path (Payment.Paths, PathFindCreateRequest.Paths), AuthAccount (AMMBid), AuthorizeCredentialEntry and AuthorizeCredentialBody (DepositPreauth). That let a member read from one node's response ride back out inside a transaction the user never put it in. Worse, StObject.FromJson passes signingOnly only to the top level: a nested unknown member reaches the displayed tx_json but never the signed blob. Show one, sign another - the failure this branch exists to remove, arriving from the outgoing side. The rule is reachability from the request graph, not the shape of the name, and each of the four now carries a comment saying so. Alongside it, four defects the same review surfaced: - RawTransaction disagreed with the typed Transaction when the later envelope was JSON null: a null still produces a non-empty slice, while the setters discard it (`value ?? _transaction`). Null envelopes are now treated as absent. - The case-insensitive name match allocated: reader.GetString() materialized every top-level key of every frame, ~760 B per scan and twice per stream event, on a struct built to avoid materializing anything. ValueTextEquals handles the ordinary case; only a differently-cased or escaped key pays. - account_history_tx_index/_boundary/_tx_first are declared properties now. rippled sends all three on every event of an account_history subscription and capture costs ~464 B per member, measured at ~796 B per event. - LOLedger, LedgerEntity and LedgerBinaryEntity each redeclared UnknownFields over a base that had since gained it: CS0108, and the base property stayed null while the data sat on the subclass - LedgerClosed hands callers an LOBaseLedger, which would have read empty. Three tests were added for holes the review proved by mutation: the null envelope, case-insensitive HasTopLevelProperty, and BaseStream.Type staying null rather than reading as UNKNOWN. Each is proven to fail against the previous behavior. The empty KnownLostMembers comment claimed a guarantee the test no longer gives - deleting a declared property leaves the suite green now that capture writes it back. It says what it actually guards instead. * fix(models): address CodeRabbit review on the stream and capture work Four findings, each verified against the code before acting on it. ServerFeaturesConverter raised JsonException on a present-but-null member: TryGetProperty answers true for `"ledger_index": null`, and GetUInt64/GetBoolean then throw, failing an entire response over a member the feature handler does not even emit. Reproduced, then guarded on ValueKind - a node sending null is saying it has no value, which is what null means here too. IBaseLedgerEntity now declares UnknownFields. LOLedger.LedgerEntity is typed as that interface, so captured fields were unreachable without casting to a concrete type - and which concrete type depends on whether the request asked for binary. The duplicate-envelope test asserted on `"Sequence": 1,` with a trailing comma. Sequence is the last member of the first envelope, so no comma follows and the substring never occurred: the assertion passed even when RawTransaction picked the first occurrence. It discriminates on Fee now, which differs between the two and sits mid-object in both. CHANGES.md still said a re-serialization of the typed LedgerStream drops network_id. It did until BaseStream gained [JsonExtensionData] in this same branch - the line two paragraphs down already recorded that the assertion was flipped. Both behavioral fixes are pinned by tests proven to fail against the previous behavior. * perf(json): compare property names in place, including non-matching ones The case-insensitive match added earlier only avoided allocating when the key matched. Every *non*-matching top-level member still went through GetString(), which is most of them: measured 1 056-1 136 B per scan on an ordinary stream frame, and the fast path never helped HasNextPage at all, since `marker` is absent on a last page and every member gets walked. Names are compared against the ASCII u8 literals this code looks up (tx_json, transaction, marker), so folding ASCII in place is exactly as permissive as the serializer's OrdinalIgnoreCase for anything that could match one. Only an escaped name still needs unescaping, and those are rare enough to pay alone. Now 0 B whether the key comes first, comes last, or is absent. Folding is guarded to letters: '_' (0x5F) and '?' (0x3F) differ by the same 0x20 bit that folds case, so an unconditional flip would match one for the other. Pinned by test. * fix(json): skip null occurrences while picking the last one A fourth way RawTransaction and the typed Transaction could disagree, found by review after the first three were closed. `{"tx_json":{...},"tx_json":null}` - the setters run `value ?? _transaction` per occurrence, so the typed side keeps the object, while the slice resolved to the trailing null and WithoutJsonNull then emptied it. RawTransaction came back empty against a Transaction still holding a payment: a wallet shown nothing while it signs something. Verified by execution before and after. Filtering moved inside the scan and onto the token type, which is both more precise than matching four bytes and composes with the duplicate rule instead of overriding its result. WithoutJsonNull is gone. The letters-only folding test could not fail. It asserted on '_' (0x5F) against '?' (0x3F), which differ by 0x60, not the 0x20 that folds case - no fold could have confused them, and removing the guard entirely left the test green. Both the test and the production comment stated that arithmetic wrongly. It now uses pairs that do differ by exactly 0x20 ('[' against '{', '_' against DEL), proven red by mutation. account_history_tx_index/_boundary/_tx_first had no test at all: a typo in any of the three [JsonPropertyName] attributes would route the field back into capture, which round-trips either way and so fails no fidelity check. CHANGES said "Four ways" over a list of three, kept "Both are the defect" from when there were two, counted 48 BaseMethodResult derivations where removing Path left 47, and claimed all three account_history members arrive on every event - only tx_index does. * docs(json): state what these methods actually do Review of the raw-frame core found no defects in the mechanics - bounds hold on malformed input, nothing aliases the pooled receive buffers, the hot path allocates zero bytes in every scenario measured, and the CHANGES figures match independent measurement. What it did find was three places where the words and the code disagreed. OfDocument documented a `default` return for a buffer holding no value. Read() raises on an empty or whitespace-only buffer rather than returning false, so that branch was dead and the promise false. It documents the exception instead, and drops the unreachable check. HasTopLevelProperty still described matching through GetString() with OrdinalIgnoreCase - that was replaced by the allocation-free NameMatches two commits ago. It also throws on a malformed window, which the SDK's own windows never are, but the type is public and constructible over arbitrary bytes. The stream channel logged "channel full, message dropped" on a TryWrite that returns false - under DropOldest it never does. The eviction is real and worth knowing about; the log line could not fire. * fix(wallet): refuse to decode a blob into a model that would lose a field Review of the signing path found a sixth instance of show-one-sign-another, this one in a flow the library itself recommended. SignatureResult.GetTx() decodes a signed blob into a typed transaction, and System.Text.Json drops what no property declares. CounterpartySignature and SponsorSignature are in definitions.json and survive the codec, but no request model declares either - so the co-signature vanished: decoded blob has CounterpartySignature : True model re-emits CounterpartySignature : False The XML doc on SignAsLoanCounterparty prescribed exactly that round trip (`brokerWallet.Sign(withCounterparty.GetTx())`), producing a final blob without the co-signature and no error anywhere. The working flow is LoanSigningHelper.BrokerSign, which stays at the blob level and strips the co-signature only to compute the preimage. The doc now says so and says why. GetTx compares the decoded blob against a re-serialization of the model and throws naming what would have been lost. That closes the class, not just these two fields: any member the models trail the protocol on now fails loudly. TransactionRequestConverter.Read caught JsonException and returned the bare instance - a transaction carrying only its TransactionType, indistinguishable downstream from a real one, and signable. Malformed input is an error again. Two protocol flags were missing entirely, found by diffing every flag enum against rippled's TxFlags.h - a surface nothing in the test suite covers, unlike ledger flags: PaymentFlags.tfSponsorCreatedAccount 0x00080000 (XLS-68) MPTokenIssuanceCreateFlags.tfMPTCanHoldConfidentialBalance 0x80 Both had their counterparts already modelled, which is what made the gaps invisible. * docs(payment): correct what the codec does with DeliverMax The rationale for the Payment/PaymentResponse asymmetry said an object re-emitting "DeliverMax" would have the codec "silently drop the amount from the signed blob". Checked by execution, that is only half right and names the wrong half: DeliverMax + EncodeForSigning -> InvalidJsonException: unknown field DeliverMax + Encode -> OK, blob carries Amount: False Signing raises; the silent drop belongs to a direct Encode. On the Sign path the loud failure comes first, since ComputeSignature runs before the final blob is built. The conclusion stands - Amount is always written - but "it throws instead" is not the reason to relax the rule, and nothing stops a caller from reaching Encode directly. Also documented on PaymentResponse.Amount: one value behind two names is exact for every response rippled produces, and inexact if they ever disagree. Given Amount 1000000 with DeliverMax 999, the later setter wins and both names re-serialize as 999 - Amount then reads as a value never sent. rippled cannot produce that (the v1 path duplicates one field rather than computing two), so it is a property of malformed input; Raw keeps the truth for anyone who needs to detect it. * refactor(client): await the call, not a parenthesised expression Reading the projection off a client call read `(await client.ServerFeatures()).Result`: the call parenthesised so the member access lands on the awaited value rather than the task, reaching a member spelled exactly like Task.Result. Nothing blocks there - XrplResponse<T> is a struct and the await has already completed - but the line reads as sync-over-async to anyone scanning it, and to analyzers looking for that shape. Across 142 call sites that is a lot of misreading. XrplResponseTaskExtensions.Typed<T> puts the await back where it belongs: ServerFeatures f = await client.ServerFeatures().Typed(); All 142 sites rewritten, along with the README examples, which now show the three equivalent forms - Typed() for the projection alone, deconstruction for both, awaiting the call itself for the envelope. Where a call already had ConfigureAwait(false), Typed() goes before it (`.Typed().ConfigureAwait(false)`) - the extension takes a Task, not a ConfiguredTaskAwaitable. No implicit conversion to T was added; that was weighed and rejected earlier for reasons recorded on XrplResponse<T>, and this changes none of them. One real sync-over-async turned up while sweeping: TestAutofillDeteteBlockers called `.Result` on the Task from Autofill, blocking a test thread. It awaits now. * test(models): guard the boundary between read shapes and sent shapes Unknown-field capture belongs on what a node sends us, never on what we send a node. A shape reachable from a request goes back on the wire, so a member captured off one response rides out inside a transaction the user never wrote - and StObject.FromJson passes signingOnly only to the top level, so that member reaches the displayed tx_json and not the signed blob. That already happened: Methods.Path, AuthAccount and the AuthorizeCredential pair all carried capture, because the exclusion list was written by type name while the property that matters is reachability from the request graph. No naming convention expresses that, so this walks it instead: from every *Request and every ITransactionRequest, across properties, and fails on any reachable type carrying [JsonExtensionData]. Two ways this test was useless before mutation testing found them: - Unwrap peeled one level of generics. Payment.Paths is List<List<Path>>, so one peel yields List<Path>, which is outside Xrpl.Models and gets discarded - Path was never reached and reintroducing the defect stayed green. - DeclaredOnly missed inherited capture, which is how it nearly always arrives: 47 models get it from BaseMethodResult, and the original defect was Path deriving from that base. Both mutations now turn it red, checked in both directions. Rooted at ITransactionRequest rather than ITransactionCommon: response types implement Common too, which drags in transaction metadata and reports BaseLedgerEntry via ModifiedNode.FinalFields - a response shape doing its job. Common gives 207 roots and that false positive; Request gives 124 and none. * fix(sugar): fail as a protocol error when the ledger shape is not what was asked for Two review findings, both verified against the code first. GetLedgerIndex and DomainAccess cast LOLedger.LedgerEntity outright, and that cast has two failure modes, neither of which reads as a protocol error: a reply with no "ledger" member casts to null and faults on the next dereference, while a binary reply deserializes to LedgerBinaryEntity and throws InvalidCastException. Both now raise ValidationException, and the binary case names the type it got, since the fix is on the caller's side. ServerFeaturesConverter checked ValueKind == Number before GetUInt64, which is not sufficient: GetUInt64 still raises FormatException for a value outside the range - a negative ledger_index, or one past ulong.MaxValue. TryGetUInt64 answers false for those, so a malformed number degrades to "not sent" rather than failing the whole response, which is what the surrounding code already does for the other members. Both are pinned by tests proven to fail against the previous behaviour: removing the guard turns the two ledger-shape tests red, and the converter test covers -1, ulong.MaxValue + 1 and a non-integer. * fix(sugar): check the containers before reading through them Three more findings from the same review, all verified first. FetchReserveFee and GetXrpFreeBalance read data.State.ValidatedLedger.ReserveInc and tested only the leaf. A response missing either container faulted with a NullReferenceException on the way to the check rather than reaching it - and in Balances the comment beside it already promised to "fail loudly", which it only did for the value, not for the shape. Both check the containers first now. JsonSlice.OfDocument returned the first top-level value's bounds without looking for a second. A buffer holding more than one value is not the single document the slice claims to span, so describing part of it as the whole was wrong even though the deserializer rejects such a frame before AttachFrame ever runs. RawJson.WriteTo keeps skipInputValidation: true, and the reason is now measured rather than asserted - validating costs 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. What the comment did not say, and now does: that premise holds by construction only for windows the SDK produced. One built through the public constructor over arbitrary bytes has its bounds checked and its contents not, so malformed input is written through verbatim. * fix(json): validate a public RawJson window once, at construction Follow-up review pointed out that documenting the WriteTo risk does not remove it, and preferred splitting the paths. It is right, and the split keeps the measured performance: validating on every write cost ~10x (3.26 -> 29.45 us on a 36 KB window), while validating once at construction costs nothing per write. The public constructor now checks the window holds exactly one JSON value. The five internal call sites - the RawResult/RawId/Raw/RawRequest/RawTransaction property reads - go through RawJson.Trusted, whose bounds came from Utf8JsonReader.Skip() and are well-formed by construction. Bounds are still checked on both paths; only the content scan differs. Frame aliasing is now stated rather than implied: RawJson reads through to the array, so mutating it afterwards changes what the window sees, and no check can catch that. ToArray is the way out. Pinned by test. Three regression gaps the same review found, all confirmed by mutation before being closed: - the reserve guard in Balances was untested - only FetchReserveFee had a case, so removing GetXrpFreeBalance's check left the suite green - JsonSlice.OfDocument's new trailing-value check had no test at all - the public RawJson constructor had no case for a partial or malformed window TestURawJsonEqualityIsIdentityOfTheWindow used arbitrary byte ranges as stand-in windows ("{\"a" and friends), which the constructor now refuses. It uses two complete values from one frame instead, which is what it was actually testing.
…нению (#108) * feat(client)!: stream events on IXrplClient, forwarded to the connection The raw bytes this release puts on stream events exist so a wallet can show a person what 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. Code written against IXrplClient could neither subscribe nor be exercised against a substitute client, so the feature had no contract to reach it through. All 16 events Connection raises are now declared on IXrplClient. The existing client.connection.OnX form keeps working; this adds a surface rather than moving one. Forwarded, not relayed, and that is the design rather than an implementation detail: add/remove reach the same Connection directly, so the client holds no delegates, no subscriber list of its own, and no subscription that nothing ever removes. A relaying version would introduce all three plus a second place to keep in sync. Safe because the Connection outlives the client - assigned once in the constructor, and ChangeServer swaps the session inside it rather than the object, so subscriptions survive a server change. IXrplClient.connection loses its setter (breaking, though nothing in the tree assigned it). With handlers attached through these events, 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. The forwarding shape is pinned by a test that crosses surfaces - subscribe through the client, remove through the connection - because removing through the surface it was added to passes either way. Proven by mutation: a relaying implementation turns it red. FeeTestClient now declares the events, which is the tell that this was the missing piece: a substitute client could not carry them while they lived only on Connection. * test(client): cover the client's own remove accessor, and let the compiler hold the invariant Review found the forwarding tests never executed the client's remove accessor: the existing case subscribes through the interface but removes through the connection, so turning `connection.OnX -= value` into `+=` left the entire suite green. Half the unsubscribe contract was unverified. Added the mirror case - subscribe through the connection, remove through the client - which turns that mutation red. Renamed the existing test to say what it actually does rather than what its name claimed. connection is get-only now instead of `private set`. The one-assignment invariant that forwarding depends on was held only by convention inside an 1100-line class; a second assignment would have compiled and stranded every handler on the old object. The compiler checks it now.
…ия и размер очереди (#109) * feat(client): count stream messages dropped when handlers fall behind Stream events reach handlers through a bounded queue, so a slow handler costs events instead of stalling the socket. That trade is right. What it left behind was no trace at all: the queue discards 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. ConnectionOptions.StreamMessageQueueCapacity (default 10 000, unchanged) sizes the queue for consumers that would rather spend memory than miss events, or the reverse. The counter is incremented from Channel's itemDropped callback, which runs inside TryWrite on the receive loop. It does nothing but increment for exactly that reason: raising an event or logging there would put consumer code back on the path this queue exists to keep it off. Worth recording, since #105 claims otherwise: handlers do not run in the receive loop. ProcessStreamMessageAsync is called by a background reader; the receive loop only calls TryWrite. The silent loss is the real defect, and that is what this addresses. The first version of the test proved nothing - it never connected, so StartMessageProcessor had not run, there was no queue to overflow, and messages took the direct fallback path. It connects to a scripted server now, and waits for delivery rather than reading the counter straight after sending, since queued delivery is asynchronous. Proven by mutation: an itemDropped callback that does not increment turns it red. * fix(client): address review on the drop counter Three findings, each verified first. The eviction test asserted DroppedStreamMessages > 0, which also passes if the callback misses some. The arithmetic is exact - the reader holds the first frame and is stuck on it, so twelve writes into a capacity-two queue evict exactly ten - and the assertion says so now. Checked over repeated runs for flakiness. Both tests released the blocked handler and disconnected only after their assertions, so a failure left the handler parked on an unfinished task and the client connected. Cleanup moved into finally. Under WebAssembly none of this applies: EnqueueStreamMessage dispatches each frame directly rather than queueing it, so the capacity is not consulted, nothing is evicted, and the counter stays at zero however far handlers fall behind - the backlog there is bounded by memory alone. Documented on the counter, on the capacity option, on the interface member and in CHANGES, rather than changed: routing browser frames through the queue needs verifying in a real browser, which nothing in this repository can do.
#111) * fix(client)!: one stream path, browsers included Closes #110. Under WebAssembly EnqueueStreamMessage started one fire-and-forget task per frame instead of writing to the queue. Everything the queue provides was absent there: StreamMessageQueueCapacity did not apply, DroppedStreamMessages 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 last one silent and worse than loss, since it leaves state wrong rather than incomplete. The bypass looked like a leftover rather than a decision: StartMessageProcessor says the queue exists for "true async support in WebAssembly single-threaded environment". Confirmed by measurement rather than by reading, in the Blazor demo against mainnet: through the queue 1 004 transactions / 52 s 19.2 tx/s 13 ledgers on the bypass 462 transactions / 33 s 13.9 tx/s 8 ledgers No console errors, timestamps in order. The background reader is scheduled fine under WASM, so the browser branch is gone and there is one path on every target. The caveats added a commit ago - on DroppedStreamMessages, on the capacity option, on the interface member and in CHANGES - described a limitation that no longer exists, so they are removed rather than left to mislead. * docs(client): say what the single stream path does and does not cover Review pointed out the claim was too broad: with the browser bypass gone the platforms match, but frames can still miss the queue - StartMessageProcessor runs at the end of OnceOpen, after the OnConnected callback, and a handler that subscribes there can see answers before the channel exists. Those take the fallback, outside the capacity, the eviction count and the ordering. Moving the start ahead of the callback was tried and reverted, because it makes things worse in a way worth recording: StartPingTimer calls StopPingTimerSync, which stops the message processor as well, so the earlier start is torn down moments later and every frame takes the fallback from then on. The eviction test drops from 10 to 0. Stopping a ping timer should not stop the message queue, and untangling that is its own change - #113. So the wording now states the window rather than claiming it away, and the ordering constraint is written down at both StartMessageProcessor and EnqueueStreamMessage so the next person does not rediscover it by breaking a test. * fix(client): кадры уходящего сокета больше не выдаются за текущие Идентичность сессии была протянута во все колбэки жизненного цикла - OnceOpen, OnConnectionFailed, OnceClose сверяются с _activeSession.SessionId - и путь сообщений оставался единственным исключением: ws.OnBinaryMessage звал IOnMessageFastPath(m) вообще без сессии. Это важно потому, что уход сокета не мгновенный. RetireOldSessionAsync работает fire-and-forget рядом с новым соединением и закрывает старый сокет *штатно*, то есть тот продолжает доставлять всё время рукопожатия закрытия - с живым колбэком. Всё, что он присылал, попадало в очередь новой сессии. После переподключения такие кадры просто устаревшие. После ChangeServer между сетями - хуже: переключились с mainnet на testnet, а обработчик, считающий что он на testnet, получает хвост потока mainnet: транзакции по счетам, которых там нет, индексы леджеров из другой цепи, и ничто не помечает их как принадлежащие прошлому соединению. Теперь сессия едет вместе с кадром и сверяется перед записью в очередь. null-сессия означает, что вызывающему нечего назвать (OnMessage, доступный кому угодно), и в этом случае не отбрасывается ничего. Connection.StaleSessionFramesDropped считает отброшенное - отдельно от DroppedStreamMessages, потому что смысл разный: ненулевое значение здесь нормально сразу после переподключения, а там означает, что потребители не успевают. Тест проверен мутацией: со снятой сверкой сессии кадр доходит до обработчика и тест краснеет. Closes #112 * fix(client): совпадения id мало — кадр уходящей сессии тоже отбрасывается Сверка только по SessionId пропускала тот самый случай, ради которого всё и делалось. ChangeServer и цикл переподключения зовут MarkAsRetiring() на сессии, пока она ещё _activeSession: замену ставит только ConnectInternalAsync, позже. Кадры, пришедшие в этом окне, несут id ровно той сессии, которую сейчас хоронят, — и проходили сверку. Теперь флаг retiring входит в проверку, и проверка идёт под _sessionLock — так же, как в OnceOpen и остальных колбэках жизненного цикла. Замок здесь не формальность: IsRetiring — обычный bool, и публикуется он только через _sessionLock, так что чтение без замка не гарантирует увидеть запись вообще. Чтобы окно было воспроизводимо в тесте, добавлен internal-шов MarkActiveSessionRetiringForTests(): помечает активную сессию ровно так, как это делают оба продакшн-пути, и возвращает её id. В продакшне это окно достижимо только гонкой с реальным переподключением, а id хоронимой сессии иначе не наблюдаем. Тест проверен мутацией: со снятой клаузулой !IsRetiring краснеет ровно новый тест, два прежних остаются зелёными. * fix(client): сессия едет в очереди, и сверка идёт перед вызовом обработчиков Сверка только на входе в очередь ничего не гарантировала. Канал пересоздаётся под каждую сессию в StartMessageProcessor, и подмена идёт под _messageProcessorLock, а не под _sessionLock: между ответом «кадр от живой сессии» и записью в _streamMessageChannel сессия успевает уйти, а её замена — поставить новый канал, и кадр попадал уже в него. Окно шире, чем просто гонка двух строк: кадр, законно принятый у живой сессии, может пролежать в очереди до StreamMessageQueueCapacity кадров (10 000 по умолчанию) и быть выдан обработчикам после того, как ChangeServer увёл клиента в другую сеть. Поэтому сессия теперь едет вместе с кадром через очередь — Channel<SessionFrame> вместо Channel<byte[]> — и сверка идёт дважды: на входе, чтобы не занимать слот очереди, и в ProcessSessionFrameAsync прямо перед вызовом обработчиков. Второе и есть гарантия; первое — только экономия. Обе ветки, очередь и запасной путь fire-and-forget, идут через одну точку. Сама сверка вынесена в IsFromLiveSession — один замок на кадр против разбора JSON и вызова обработчика. Шов для тестов разделён на чтение и действие: ActiveSessionId и MarkActiveSessionRetiringForTests(). Новый тест детерминирован без гонок: процессор — единственный читатель и ждёт каждый обработчик, поэтому обработчик, который не возвращается, держит второй кадр в очереди сколько нужно. Проверено мутацией: снятие сверки на выдаче красит ровно его, снятие сверки на входе — два других. * fix(client): отказ канала не теряет кадр, тест не зависит от старта процессора TryWrite звался ради побочного эффекта, а результат игнорировался — на том основании, что канал с DropOldest не отказывает. Для полной очереди это верно: она вытесняет старейший кадр, считает его через itemDropped и возвращает успех. Для завершённой — неверно. StopMessageProcessorInternal завершает writer уже после того, как обнулил _streamMessageChannel, поэтому тот, кто взял ссылку мгновением раньше, пишет в закрытый канал: TryWrite возвращает false, и кадр исчезал бесследно. Это не угол: StartPingTimer гасит процессор, а StartMessageProcessor поднимает его заново на каждом подключении, так что окно повторяется. Теперь отказ уводит кадр на запасной путь, где он всё так же проходит сверку сессии — очередь и запасной путь сходятся в ProcessSessionFrameAsync. Отдельно: тест на очередь опирался на то, что после Connect процессор уже поднят, а это не гарантировано вовсе. OnceOpen сначала отпускает ожидающих через connectionManager.ResolveAllAwaiting(), и только потом зовёт колбэк OnConnected и запускает процессор — то самое стартовое окно, описанное на EnqueueStreamMessage. В 20 локальных прогонах оно не проявилось, но порядок в коде именно такой, и тест теперь ждёт готовности явно, а не полагается на удачу. Швы: IsMessageProcessorRunning и CompleteStreamChannelWriterForTests(). Проверено мутацией: возврат к игнорированию отказа TryWrite красит ровно новый тест. * refactor(client): селф-ревью — раскладка документации, барьер, счётчик в интерфейсе Проверка собственного диффа поверх origin/dev. Дефект: doc-комментарий EnqueueStreamMessage оказался оторван от метода — IsFromLiveSession вставился между <remarks> и сигнатурой, так что у предиката стало два <summary>, а сам EnqueueStreamMessage остался вовсе без документации. Восстановлено. StaleSessionFramesDropped проброшен в IXrplClient рядом с DroppedStreamMessages: счётчик заводился ради наблюдаемости, а прочитать его через интерфейс было нельзя. Это новый член публичного интерфейса — внешним реализациям придётся его добавить; PR и так помечен как ломающий. IsMessageProcessorRunning читает поле через Volatile.Read: поле пишется под _messageProcessorLock, и после того как я требовал замок для IsRetiring, обычное чтение здесь было бы двойным стандартом. Полноценный замок брать нельзя — StopMessageProcessorInternal держит его до двух секунд на ожидании задачи читателя. Мелочи: комментарий над полем канала говорил «byte[]», хотя канал уже несёт SessionFrame; объявление SessionFrame больше не разрывает группу полей, а стоит рядом с предикатом, который его читает; убран остаточный блок из EnqueueStreamMessage; описание ProcessStreamMessageFireAndForgetAsync больше не называет путь браузерным — входов в него теперь три, и ни один не зависит от платформы. * fix(client): запасной путь отдаёт кадр до работы, а не после Асинхронный метод выполняется на потоке вызывающего до первого настоящего await, а первый настоящий await внутри ProcessStreamMessageAsync стоит уже после JsonSerializer.Deserialize. То есть каждый кадр, ушедший на запасной путь, разбирался на приёмном цикле — плюс всё, что обработчик успевал сделать до своего первого await. Это ровно та блокировка головы очереди, ради которой очередь и заведена, возвращённая для стартового окна, для остановленного процессора и для отказа канала. Yield в начале запасного пути её убирает. Следствие, которое стоит назвать вслух: OnMessage больше не выдаёт кадр синхронно, когда процессора нет. На пути через очередь он этого не делал никогда, так что теперь два пути ведут себя одинаково — но код, который звал OnMessage на неподключённом клиенте и читал состояние обработчика следующей строкой, опирался именно на разницу. На это опирались два теста форвардинга событий: они звали OnMessage и тут же проверяли счётчик. Переписаны на ожидание — со свидетелем, которого никто не снимает, иначе «счётчик всё ещё 1» после снятия обработчика проходило бы и для кадра, который просто ещё не обработан. Проверено мутацией: замена remove на += в аксессоре клиента по-прежнему красит зеркальный тест. Новый тест — TestUFallbackPathReturnsBeforeHandlersRun, детерминированный по построению: обработчик встаёт на блокирующее ожидание, и если бы кадр обрабатывался встроенно, вызов инъекции не вернулся бы до его освобождения. Проверено мутацией: снятие yield красит ровно его. * test(client): доставка именованной сессии проверяется отдельно Тест на доставку звал OnMessage, то есть проверял только кадр без сессии. Реализация, отвергающая любую именованную сессию, его проходила — а это означало бы, что не доставляется вообще ничего: все кадры, которые производит сокет, именованы. Добавлен TestUFrameNamingTheLiveSessionIsDelivered, прежний переименован в TestUFrameNamingNoSessionIsDelivered — теперь имя говорит, какой именно случай он держит. Проверено мутацией: с return false для любой непустой сессии новый тест краснеет, а прежний остаётся зелёным — ровно тот разрыв, о котором шла речь. Заодно уточнена формулировка в CHANGES: утверждение «приёмный цикл делает только TryWrite» верно для кадра из очереди, но кадр на запасном пути отдаётся именно с приёмного цикла. Вывод не меняется — передача уступает поток до разбора, так что обработчики на приёмном цикле не выполняются, — но механизм описан теперь для обоих путей. * test(client): проверка «обработчик не видел кадр» перестаёт быть декоративной В двух тестах на отбрасывание стояло Assert.AreEqual(0, calls) сразу после инъекции. Упасть эта проверка не могла: выдача асинхронна на обоих путях, так что недоброшенный кадр просто ещё не пришёл бы к моменту проверки. Держал тесты только счётчик StaleSessionFramesDropped. Теперь после кадра под проверкой идёт свидетель — кадр без сессии через OnMessage, который не отвергается никогда, с отличимым Sequence. Он подан позже, а читатель один и разбирает очередь по порядку, поэтому если бы кадр под проверкой вообще попал в очередь, его выдали бы раньше свидетеля. Увидели свидетеля — значит второй не придёт уже никогда. Проверено мутацией, которую прежняя форма не ловила: считать кадр устаревшим, но всё равно доставить. Счётчик при этом равен единице и первая проверка проходит; краснеют обе проверки на свидетеля. * test(client): шов помечает сессию и называет её одним замком MarkActiveSessionRetiringForTests() снова возвращает id помеченной сессии. Разделение на ActiveSessionId + пометку я сделал сам во время селф-ревью «ради чистоты» и внёс этим TOCTOU: два захвата замка позволяют переподключению подменить сессию между ними, и тест, назвавший прочитанный первым id, проверял бы уже путь несовпадения — тот самый, который держит соседний тест, — молча и только иногда. ActiveSessionId остаётся: им пользуются тесты, которые ничего не помечают, и там разрыва нет. Проверено мутацией: снятие клаузулы !IsRetiring по-прежнему красит этот тест. * feat(client): счётчик кадров мимо очереди и тела по умолчанию в интерфейсе FallbackDispatchedStreamMessages считает кадры, выданные в обход очереди. У запасного пути нет ни границы ёмкости, ни учёта вытеснения, ни порядка от единственного читателя, а снаружи не было видно ни того, что он вообще использовался, ни насколько. Уводят туда три вещи: процессор ещё не поднят, процессор остановлен, канал отказал в записи. Первое — реальное окно на каждом подключении, и теперь оно измеримо, а не только обсуждаемо. Все три счётчика объявлены в IXrplClient с телами по умолчанию, проброс в connection — единственная реализация, которая имеет смысл. DroppedStreamMessages был объявлен без тела в этом же невыпущенном цикле; дать тело и ему ничего не стоит и сохраняет компилируемость внешней реализации интерфейса. Прецедент в файле есть — SetNetworkId. Проверено мутацией: снятие инкремента красит тест на отказ канала. * docs(client): битые crefs и противоречие в CHANGES CHANGES утверждал, что внешней реализации IXrplClient придётся добавить новый член, — это осталось от версии до тел по умолчанию и противоречило соседнему пункту в том же списке. Два cref не разрешались и давали CS1574 при генерации документации: IOnMessageFastPath(string, byte[]) — сигнатура, которой больше нет, и Volatile.Read{T}(ref T), который я написал в прошлом коммите. Проверено сборкой: с ними 8 предупреждений CS1574, без них — 2, и обе оставшиеся в файлах, которых этот PR не касается (BaseResponse.cs, BookChangesStream.cs). У DroppedStreamMessages было два блока <remarks> подряд, у StaleSessionFramesDropped — собственные summary и remarks плюс inheritdoc на соседа, то есть два конфликтующих источника документации для одного члена. Оба следа от прошлого коммита: заметка про тело по умолчанию теперь <para> внутри собственных remarks каждого из трёх счётчиков. FeeTestClient объявляет и FallbackDispatchedStreamMessages: этот двойник возвращает null из connection, а тела по умолчанию пробрасывают именно туда, так что унаследованный член кинул бы NullReferenceException при первом чтении. * docs: строка таблицы описывала только половину случаев StaleSessionFramesDropped считает кадры двух видов: с id сессии, которой уже нет вовсе, и с id сессии, которую сейчас хоронят, — она ещё активна, и именно поэтому для неё понадобилась клаузула !IsRetiring. «Frames from a retiring socket» называло только второй. * docs: гарантии очереди не распространяются на запасной путь, а yield — не про поток Две неточности в формулировках, обе меняют смысл. «Capacity, eviction counting and single-reader ordering hold on every target» звучало как безусловное, хотя четырьмя пунктами ниже сказано ровно обратное про кадры на запасном пути. Оговорено: на пути через очередь и для кадров, которые в неё попали. «The loop is not the thread that deserializes» — Task.Yield() гарантирует асинхронность продолжения, а не другой поток операционной системы; под однопоточным WebAssembly это тот же поток, просто позже. Утверждение переформулировано на «не делает этого синхронно» — а больше здесь и не нужно. То же исправлено в doc-комментарии теста, где стояла та же фраза. * test(client): свидетель регистрируется после обработчика, счётчик проверяется дельтой В первом тесте форвардинга комментарий утверждал, что свидетель добавлен после обработчика, а код добавлял его до. Многоадресный делегат вызывает подписчиков в порядке регистрации, так что свидетель отчитывался о выдаче кадра раньше, чем отрабатывал обработчик, — проверка «sanity: the handler is attached» могла мигать, а не падать. Порядок исправлен, и почему он важен — теперь написано в обоих тестах, чтобы его не переставили обратно из соображений опрятности. FallbackDispatchedStreamMessages проверяется дельтой от базовой линии: счётчик живёт всё соединение, а стартовое окно, описанное в этом же файле, могло увести кадры мимо очереди до того, как тест до него добрался. Проверено мутацией: замена remove на += в аксессоре клиента по-прежнему красит зеркальный тест.
…аймер больше не гасит процессор (#114) * fix(client): очередь потока существует до того, как потребитель подпишется StartMessageProcessor стоял в самом конце OnceOpen — после ResolveAllAwaiting() и после колбэка OnConnected. Подписываться из этого колбэка — обычная практика, именно так делает потребляющий SDK кошелёк, а нода успевает ответить на подписку до возврата обработчика. Первые кадры не находили канала и уходили на запасной путь: мимо StreamMessageQueueCapacity, мимо счётчика DroppedStreamMessages и параллельно, а не по одному. То есть события, у которых больше всего шансов прийти не в том порядке, — ровно первые после подключения. Мешала этому не сама перестановка, а посторонняя связка, и её устранение и есть настоящая правка. StartPingTimer начинается со StopPingTimerSync, а тот заодно звал StopMessageProcessor: запуск процессора раньше сносился таймером через мгновение. Это измеримо — TestUDroppedStreamMessagesCountsWhatTheConsumerNeverSaw давал 0 вытеснений вместо 10. Остановка пинг-таймера не должна останавливать очередь сообщений; теперь два жизненных цикла независимы. Процессор останавливается явно там, где соединение действительно кончается: Disconnect, DisconnectAndWaitAsync, OnceClose, OnConnectHandlerFailedAsync, ChangeServer и RetireCurrentSessionAndReconnectAsync — а не побочным эффектом таймера. Запасной путь остаётся, и это проверено, а не предположено: объявление его недостижимым роняет 6 тестов. Он по-прежнему обслуживает OnMessage на клиенте, который вообще не подключался, всё пришедшее после остановки процессора и запись, которой канал отказал из-за завершённого writer. Проверено мутациями: возврат запуска в конец OnceOpen красит тест на кадр из колбэка, возврат связки в StopPingTimerSync — тест на выживание процессора после старта пинг-таймера. Closes #113 * docs(client): список мест остановки процессора был неполон В remarks к StopPingTimerSync перечислены шесть мест, где процессор останавливается явно, но ReconnectLoopAsync тоже завершает сессию и в список не попал. Уточнено, почему остановки там нет и не должно быть: до этой правки её там тоже не было, OnceClose к моменту повтора уже отработал, а StartMessageProcessor снимает остаток при следующем подключении. Добавить остановку туда значило бы отбрасывать кадры, ещё лежащие в очереди с момента до обрыва, — на пути, где ничто не показывает, что это нужно. Заодно сказано прямо, что моменты остановки не изменились: те же шесть мест, где раньше срабатывал побочный эффект. Исчезла только лишняя остановка внутри StartPingTimer. * docs(client): комментарии описывали порядок, которого больше нет; тесты ждут точку жизненного цикла Три doc-комментария всё ещё утверждали, что процессор запускается после колбэка OnConnected: на счётчике FallbackDispatchedStreamMessages, на шве IsMessageProcessorRunning и на EnqueueStreamMessage. Последний прямо приглашал вернуть окно обратно — «Untangling that is tracked separately». Все три приведены в соответствие, и в EnqueueStreamMessage отдельным абзацем записано, почему запасной путь остаётся достижимым. Формулировка про счётчик исправлена по сути, а не по стилю: он накапливается за всё соединение, а запасной путь законен вне соединения, поэтому значение имеет не абсолютный ноль, а нулевая дельта на промежутке подключения. То же в CHANGES. Оба теста ждали не ту точку. Первый читал поля, которые выставляет обработчик OnConnected, но Connect() возвращается раньше него — OnceOpen отпускает ожидающих до вызова колбэка. Теперь обработчик сигналит через TaskCompletionSource, и тест ждёт сигнал. Второй проверял, что процессор пережил старт пинг-таймера, но таймер стартует в самом конце OnceOpen, уже после возврата Connect(). Проверка могла пройти раньше, чем таймеру представился случай что-то сломать. Добавлен шов IsPingTimerRunning, и тест ждёт запуска таймера прежде, чем что-либо утверждать. Мутации перепроверены после правки тестов: возврат запуска в конец OnceOpen красит первый тест, возврат связки в StopPingTimerSync — второй. * docs(client): один remarks на член, документация помощника догоняет новый порядок Селф-ревью собственного диффа. У IsMessageProcessorRunning стояло два блока <remarks> подряд — ровно тот дефект, который я в прошлом PR находил у DroppedStreamMessages и StaleSessionFramesDropped и там исправил, а здесь пропустил. Заметка про Volatile.Read стала <para> внутри единственного remarks. <em> в remarks к FallbackDispatchedStreamMessages — единственный HTML-тег на весь файл; фраза переписана без него. Документация IsPingTimerRunning говорила «ping timer is up», хотя ценность шва точнее: StartPingTimer начинается со StopPingTimerSync, который обнуляет поле, и присваивает его уже после. Ненулевое значение означает, что шаг разрушения — тот самый, что раньше уносил процессор сообщений, — уже позади. Это и есть причина, по которой ожидание в тесте синхронизировано именно с ним. WaitForMessageProcessor в TestUStaleSessionFrames утверждал, что процессор запускается последним. После этой правки — нет. Помощник оставлен, но теперь как страж, а не необходимость: если порядок откатят, тесты упадут на нём с внятным сообщением, а не начнут тихо проверять запасной путь.
…ается, и описания не тех членов; убран мёртвый failHard (#115) * docs: doc-комментарии, которые молча теряли половину себя Уборка, накопившаяся за три PR подряд и каждый раз откладывавшаяся как несвязанная. Дифф целиком в комментариях: ни одной строки кода. Два cref не разрешались и давали CS1574 при генерации документации: - BaseResponse.cs: RequestManager без квалификации, а этот файл не импортирует Xrpl.Client; - BookChangesStream.cs: Common.IssuedCurrency связывалось с пространством имён Xrpl.Models.Common, а не с классом Common внутри него — ровно поэтому сам код двумя строками ниже пишет Common.Common.IssuedCurrency. По решению CS1574 теперь ноль. Пять членов несли по два-три блока <remarks> подряд. Дублирующийся тег не предупреждение компилятора, но генератор документации берёт один блок и молча роняет остальные, то есть половина написанного не доходит до читателя: - connection.cs, поле блокировки переподключения (из #78); - JsonSlice.cs, FindTopLevelMember — три блока; - Payment.cs, Amount — два; - Transactions/Common.cs, FinalFields — два, и оба лежали внутри <summary>, который сам не содержал текста. Здесь блоки не просто слиты: summary получил собственное предложение, а описания DeletedNode и ModifiedNode переехали в remarks абзацами. Проверено граблем по всем .cs решения: дублей <remarks> и <summary> не осталось. Сборка 0 ошибок, TestU — 1121 passed. * docs: комментарии, описывавшие не тот член, и XML, который не разбирается Продолжение уборки: три класса предупреждений генератора документации. По решению CS1570, CS1572, CS1574 и CS1587 теперь равны нулю. CS1570 — XML, который не парсится, то есть комментарий обрывается на первой же ошибке и остаток теряется целиком: - ModelUtils.cs и Submit.cs: неэкранированный & в тексте («bitwise AND (&)», «sign & encode») закрывает документ прямо там; - NFTokenMint.cs: <summary> у tfMutable не закрыт, из-за чего он поглощал следующий член; - XrpConversion.cs, три места: тег <returns без «>» — обрубок, за которым не было ни текста, ни закрытия. CS1572 — <param> для параметра, которого у метода нет. Во всех пяти случаях причина одна: комментарий описывает не тот член, на котором стоит. - BinaryParser.cs: «Consume the first n bytes» и param n висели на Peek(), который вообще без параметров, а у Skip(int n) — того самого метода — стояло «todo». Описания поменяны местами; - AccountId.cs: над FromValue(string) лежала копия документации FromParser, с параметрами parser и hint. Заменена описанием того, что метод действительно делает; - StObject.cs: параметр давно называется signingOnly, а param остался strict; - WebSocketClient.cs: у OnError документация скопирована с соседнего OnConnectionError вместе с чужим именем параметра; - DIDSet.cs: документация ValidateDIDSet стояла на константе MaxDIDFieldLength, вклинившейся между комментарием и методом. Константа переставлена выше — единственная строка кода во всём дифффе, семантически тождественная. CS1587 — <inheritdoc /> над закомментированным кодом в IXrplClient.cs: наследовать было не для чего. Заодно исправлен <returns> у GetSignedTx: он обещал «A Wallet derived from a seed», хотя метод возвращает подписанный блоб и транзакцию. Сборка 0 ошибок. Тесты: Xrpl 1121, BinaryCodec 145, AddressCodec 27, Keypairs 33 — все зелёные. * docs: hex у FromValue только в верхнем регистре, GetSignedTx ничего не отправляет Две находки ревью, обе проверены по коду. HEX_REGEX в AccountId — это ^[A-F0-9]{40}$ без ignore-case, поэтому «40 hex characters» было неточно: строчный hex не распознаётся как hex и проваливается в декодер адреса. Регистр теперь назван явно, вместе с самой регуляркой. Саму регулярку не трогаю намеренно: сделать её нечувствительной к регистру — поведенческое изменение в базовой библиотеке, а этот PR документационный. Стоит отдельного разбора: строчный hex в этом случае не отвергается внятно, а уходит в DecodeAccountID и падает там. GetSignedTx ничего не отправляет — он автозаполняет, подписывает и кодирует. Описание transaction обещало «and submit», а failHard описывался как поведение при отправке, хотя в теле метода не используется вовсе. Оба описания приведены к тому, что метод делает; failHard назван неиспользуемым и объяснено, зачем он в сигнатуре. Удаление параметра было бы ломающим изменением публичного метода расширения — тоже не для документационного PR. * refactor(sugar)!: убран параметр failHard у GetSignedTx Параметр был объявлен и ни разу не прочитан. Метод автозаполняет, подписывает и кодирует — в сеть ничего не уходит, значит и отказываться жёстко не от чего. Показательно, что Submit передавал туда явное failHard: false, сохраняя собственное значение для настоящей отправки через SubmitRequest: автор уже знал, что аргумент ничего не значит. SubmitAndWait передавал своё значение — то есть два вызывающих метода расходились в том, что делать с параметром, который не делает ничего. Ломающее для прямых вызовов GetSignedTx: аргумент убирается. Вызывающие Submit и SubmitAndWait не затронуты, их собственный failHard по-прежнему уходит в SubmitRequest. Позиционный вызов получит ошибку компиляции, а не тихую перепривязку — следующий параметр XrplWallet, bool туда не встанет. Записано в CHANGES вместе со строкой миграционной таблицы.
…дпись перестаёт молча терять члены (#116) * fix(codec)!: неизвестное поле отвергается на любой глубине, а не только сверху StObject.FromJson отказывал на членах, которых кодек не знает, но только на верхнем уровне. Вложенные объекты попадали туда через таблицу диспетчеризации, в сигнатуре делегата которой флага строгости нет, — то есть шли через ленивую перегрузку, и член пропадал без единого слова. Цена — «показали одно, подписали другое», приходящее со стороны исходящих данных: пользователю показывают транзакцию с полем (опечатка в имени или поле из поправки новее нашего definitions.json), он подписывает, отправляет, и она ложится в леджер без этого поля. Сверху падало громко, уровнем ниже — нет. rippled делает наоборот: STParsedJSON::parseObject рекурсивен и отвечает unknownField на каждом уровне. Случай с Batch оказался хуже, чем описано в задаче. BatchNormalizer .ComputeInnerTxId тоже разбирал лениво, а этот id — то, что фиксирует внешняя подпись Batch. Значит подпись закрепляла не ту внутреннюю транзакцию, которую видел пользователь. Строгость там нужна без фильтра подписи: id считается по всей транзакции, и отсев неподписываемых полей хешировал бы опять не то. Для этого добавлен StObject.FromJsonStrict. Строгость и фильтр подписи разделены. Один флаг означал сразу «отвергать неизвестное» и «отсеять неподписываемые поля»; рекурсивно уходит только первое. FilterIsSigning по-прежнему применяется к верхнему уровню — фильтрация вложенных объектов изменила бы то, что подписывается. Encode оставлен ленивым намеренно. Из двенадцати мест вызова четыре гоняют транзакцию через кодек, чтобы задать о ней вопрос — IsSigned, IsAccountDelete, GetLastLedgerSequence, ValidateTransactionEquivalence, — а HashSignedTx считает хеш транзакций, пришедших от ноды. Строгий Encode превратил бы предикат в исключение и падал бы на любом ответе с полем новее нашего definitions.json, то есть убил бы прямую совместимость. Ломающее: вызывающий, передающий транзакцию с неизвестным SDK полем, раньше получал молча урезанный блоб, а теперь исключение. Тесты — TestUStrictNestedFields, семь: поле внутри Memo, внутри обычного объекта, на верхнем уровне, id внутренней транзакции Batch, плюс три контрольных — корректная транзакция подписывается, id считается, Encode по-прежнему не жалуется. Проверено мутациями: возврат вложенных на ленивый путь красит ровно два теста, возврат txid на ленивый разбор — ровно один. Closes #107 * fix(codec)!: объектные поля вне StObject тоже перестают глотать неизвестные члены Находка ревью: рекурсия через StObject и StArray не достаёт до типов, которые разбирают объект сами. Три таких: Issue в форме MPT, XChainBridgeType и шаги PathSet. Каждый читал свои именованные члены и молча проходил мимо остальных — то есть та же потеря, только на уровень ниже найденного. У Issue две другие формы всегда считали члены (obj.Count != 1 и != 2), так что форма MPT была единственной, где счёта не было. Теперь считает и она. XChainBridgeType требует ровно свои четыре члена. С шагами пути сложнее, и наивная строгость сломала бы обычный сценарий. ripple_path_find отвечает полем type на каждом шаге, модель Path его объявляет, а PathHop.ToJson излучает обратно — значит путь, взятый из ответа и вставленный в платёж, его несёт. Байт типа кодек синтезирует сам из того, какие из account, currency и issuer присутствуют, поэтому член избыточен, а не неизвестен. Допустимый набор задан явно: account, currency, issuer, mpt_issuance_id, type, type_hex — последний мёртв с rippled 1.7.0 и допущен ради старых данных. Тестов стало одиннадцать. Ключевой — TestUPathStepFromTheNodeStillSigns: проверено мутацией, что при удалении type из допустимого набора он краснеет единственным на полном прогоне из 1132, то есть он и есть то, что стоит между этой правкой и сломанным путём через path-finding. Проверка: юниты 1132 / BinaryCodec 145 / AddressCodec 27 / Keypairs 33, интеграционные 265 — 0 падений. * docs(codec): заявление про ленивый Encode было верно лишь наполовину Селф-ревью. После правки листовых типов Encode стал строг к неизвестным членам внутри шагов пути, MPT-формы Issue и XChainBridge — проверено зондом, а не рассуждением. При этом и CHANGES, и описание PR утверждали, что Encode остаётся ленивым. Код оставлен как есть, исправлено утверждение. Граница проходит между открытой формой и закрытой. Транзакция обрастает полями с каждой поправкой, и выброшенное поле оставляет валидную транзакцию без одного поля. У Issue, XChainBridge и шага пути форма фиксированная: член вне набора вообще не сериализуется, поэтому его пропажа даёт не ту же структуру без члена, а другую структуру — и любой блоб или хеш поверх неё просто неверен. Заодно факт, который делает это не новой политикой: две стандартные формы Issue считали свои члены задолго до этой правки, то есть Encode никогда не был ленив полностью. Ещё два пункта селф-ревью: - StObject.FromJsonStrict — новый публичный член базового пакета (публичный вынужденно: между Base и Xrpl нет InternalsVisibleTo). Назван в CHANGES; - записи таблицы диспетчеризации для StObject и StArray больше не тот путь, которым идёт ParseObject. Сегодня они ему эквивалентны, но это две дороги, которые могут разойтись, — над таблицей написано, почему они остаются и что их надо держать эквивалентными. * fix(codec): члены XChainBridge проверяются по именам, а не по их числу Находка ревью, и она верна: счёта мало. Три правильных члена плюс один посторонний тоже дают четыре — проверка проходила, а недостающий уходил в AccountId.FromJson или Issue.FromJson как null. Вызывающему сообщалось то, что эти методы делают из ничего, вместо имени члена, которого быть не должно. Теперь требуются все четыре имени вместе со счётом. Тест — TestUXChainBridgeWithTheRightCountButTheWrongMembersIsRefused, проверен мутацией: возврат к проверке одного лишь счёта красит ровно его. * chore(codec): Xrpl.BinaryCodec 10.11.0.0 -> 11.0.0.0 Пакет меняет поведение для входа, который раньше проходил, и получает новый публичный член FromJsonStrict, поэтому берёт мажор вместе с Xrpl, а не остаётся на 10.11. Xrpl.AddressCodec и Xrpl.Keypairs с прошлого релиза не менялись и остаются на 10.9.0.0. Это не забывчивость: базовые пакеты подключены через ProjectReference, и Xrpl, собранный на новой версии, продолжит зависеть от уже опубликованных. Версия самого Xrpl (10.12.0.0 -> 11.0.0.0) относится к промо-PR dev -> release, где считается весь накопленный объём ломающих изменений, а не к этой ветке.
…мер нашёл десять вместо семи (#120) * feat(models): поля ответов, которые нода шлёт, получают типизированные свойства Захват неизвестных полей сделал потерю видимой вместо молчаливой; это то, что он нашёл. Захват — страховка, объявление — собственно починка, и поле считается сделанным только когда оно объявленное свойство И исчезло из UnknownFields: любая половина по отдельности проходит, пока другая провалена. ServerInfo.Info получает десять полей, а не семь из задачи. Замер против ноды вместо работы по списку нашёл ещё три: git, node_size, validator_list. Тест поэтому проверяет пустоту всего захвата, а не перечень имён — перечень был бы перечнем из задачи и промахнулся бы ровно мимо тех, о которых никто не подумал. Типы тоже взяты у ноды, а не из документации. server_state_duration_us в server_info — строка, при том что то же поле в server_state объявлено числом; initial_sync_duration_us, jq_trans_overflow, peer_disconnects, peer_disconnects_resources и time — тоже строки. ports — список {port, protocol[]}, git и validator_list — объекты, поэтому появляются три маленьких типа. AccountLines.Validated — единственная родственная модель результата, где его не объявили, хотя rippled пишет его через lookupLedger безусловно. LOLedger.ClosedLedger и LOLedger.OpenLedger — вызов ledger без указания леджера отвечает двумя целыми структурами. Не путать с BaseLedgerEntity.Closed, который булев признак внутри леджера: одно слово на два разных понятия и есть причина, по которой их не замечали. LOEscrow.Flags вместо //todo, рассуждавшего, что всегда нулевое поле незачем моделировать. Это путает «всегда ноль» с «никогда не присылается»: поле приходит на каждом удалённом узле Escrow в метаданных. Простое число, а не перечисление, потому что ни одного флага lsfEscrow* не определено, и пустое перечисление заявляло бы словарь, которого нет. Проверено мутацией: объявление свойства под именем, которого нода не шлёт, красит тест на server_info — то есть проверка «объявлено и ушло из захвата» работает обеими половинами. * test(escrow): ожидание закрытия леджера двигает цепочку, а не ждёт чужой таймер TestMPTEscrowCreate_AndCancel падал примерно в половине полных прогонов, и списать это на нехватку бюджета не вышло: на холостом стенде close_time идёт почти секунда в секунду с реальным временем — 92 секунды за 94, шагами вида +1, +1, +8 с округлением к кратным десяти. То есть цель на 24 секунды вперёд обязана достигаться примерно за столько же, а не за минуту. Сначала сделал падение самообъясняющим, и оно сразу назвало причину: «Last close time seen: 23:36:20 (short by 2.0s), polls: 20, last error: none». Шестьдесят секунд ожидания купили двадцать две секунды времени закрытия — под параллельной нагрузкой вызовы ledger_accept от контейнера-акцептора становятся в очередь за трафиком тестов, леджеры закрываются реже, и время закрытия отстаёт от реального. Поэтому ожидание больше не ждёт чужой таймер, а само просит закрыть леджер: следующий валидированный несёт время закрытия, взятое по часам сейчас. Стенд, который откажет в команде, оставляет ожидание ровно таким, каким оно было. Заодно у помощника исправлены две вещи, мешавшие разбираться: сообщение об ошибке не несло ничего, кроме цели, — теперь в нём последнее увиденное время закрытия, недостача в секундах, число опросов и последняя ошибка; и цикл крутился maxWaitSeconds + 10, сообщая при этом про maxWaitSeconds. Проверено: три полных интеграционных прогона подряд — 265/265, и набор стал быстрее, 4m31s..4m55s против прежних ~5m30s: ожидания escrow заканчиваются раньше вместо того, чтобы вырабатывать бюджет опросами. * fix(tests): ошибка ledger_accept больше не съедает опрос, а бюджет не переезжает Четыре замечания ревью, все верные. Главное: doc-комментарий обещал, что стенд, отказавший в ledger_accept, оставляет ожидание прежним — а код обещание не выполнял. Вызов стоял в том же try, что и чтение леджера, поэтому недоступный или неавторизованный ledger_accept выбрасывал мимо чтения, и помощник переставал смотреть на цепочку вовсе. То есть падал бы по таймауту даже тогда, когда леджеры закрывает кто-то другой — ровно тот запасной путь, который комментарий и описывал. Закрытие леджера теперь в своём try, чтение выполняется всегда, а обе ошибки попадают в сообщение по отдельности. Второе: цикл проверял бюджет перед фиксированной задержкой, так что опрос мог начаться под самой границей и уйти запросами за неё. Бюджет — это бюджет, а не стартовый выстрел: теперь остаток решает и продолжать ли, и сколько спать. Третье: проверка «захват пуст» смотрела только верхний уровень. У Git, ValidatorList и ServerPort свои захваты, и регресс имени hash или expiration прошёл бы незамеченным — ровно та форма промаха, ради которой всё это и делалось. Проверяются все, плюс сами значения. Проверено мутацией: git.hash под чужим именем красит тест, раньше не покрасил бы. Четвёртое: в CHANGES стояло «тринадцать» — это число из задачи, а полей четырнадцать: десять плюс одно плюс два плюс одно. Своя же арифметика противоречила своему же списку. Проверка: юниты 1137/0, интеграционные на стендалон-ноде два прогона 265/265.
…m.IO.Path и с кодеком (#121) * refactor(models)!: Models.Path становится Common.PathStep, Utils.Index — ModelUtils Тип описывает один шаг одного пути, а не путь, и старое имя стоило трёх разных вещей. Столкновение с System.IO.Path. Доказательство лежало в самом репозитории: TestUResponseFidelity был единственным тестовым файлом, импортирующим сразу System.IO и Xrpl.Models.Methods, и вынужденно писал System.IO.Path.Combine в трёх местах, тогда как соседи писали просто Path.Combine. Эти три квалификации здесь сняты — и то, что сборка после этого зелёная, и есть проверка, что конфликт ушёл вместе с ними. Потребители платили больше: при включённом ImplicitUsings одного using Xrpl.Models.Methods хватало, чтобы любое обращение к Path.Combine в файле стало CS0104. Столкновение с Xrpl.BinaryCodec.Types.Path, который как раз путь целиком. Одно имя означало контейнер в одной половине SDK и его элемент в другой. Ни один файл не импортировал оба пространства имён, поэтому до отказа не доходило. Кодек своё имя сохраняет — там оно верное. Расхождение с окружением: PathStepType, Validation.IsPathStep, TestUPathStep и xrpl.js, где это PathStep, а Path = PathStep[]. List<List<Path>> читался как список списков путей, означая список путей. Формат на проводе не меняется: правится только C#-имя, JsonPropertyName на account, currency, issuer, mpt_issuance_id и type не тронуты. Моста нет намеренно: [Obsolete] class Path : PathStep не помог бы, потому что дженерики инвариантны и List<List<Path>> всё равно не приводится. Он бы только добавил тип в публичную поверхность, не собрав ничего нового. Попутно Xrpl.Models.Utils.Index → ModelUtils, тот же класс дефекта уровнем выше. Index — калька с barrel-файла utils/index.ts и столкновение с System.Index, который в области видимости всегда. В Payment.cs ради обхода стоял псевдоним using Index = Xrpl.Models.Utils.Index; — он удалён за ненадобностью. Класс заодно совпал с именем своего файла ModelUtils.cs. Версия Xrpl поднята до 11.0.0.0. Xrpl.BinaryCodec уже на 11.0.0.0, Xrpl.AddressCodec и Xrpl.Keypairs остаются на 10.9.0.0 — с прошлого релиза не менялись. Проверка: юниты 1137/0, интеграционные на стендалон-ноде 265/0, TestIPathPayment 4/0. Closes #117 * refactor(models): осиротевшие using Methods удалены, cref FilterIsSigning починен Селф-ревью собственного диффа. После переезда типа два файла перестали нуждаться в using Xrpl.Models.Methods вовсе — Payment.cs и TestUPathStep.cs. Проверено удалением: сборка чистая. Оставлять их значило бы сохранить ровно тот импорт, из-за которого Path.Combine и переставал компилироваться; теперь эти файлы не тянут пространство имён, ради развода с которым всё и делалось. Отдельно — регресс, который я сам внёс и не заметил: в #115 я вычистил CS1574 по решению до нуля, а в #116 добавил cref="FilterIsSigning" на метод, объявленный в другом классе, и снова получил предупреждение. Заменено на <c>. По решению CS1574 снова ноль. Мелочь: вставленный using Xrpl.Models.Common оказался в конце блока, а не по алфавиту. Проверка: юниты 1137/0, интеграционные на стендалон-ноде 265/0. * docs(tests): пример типа в remarks догнал переименование Находка ревью. В TestUOutgoingShapesCarryNoCapture я поправил упоминания вида <c>Path</c>, но пропустил два внутри экранированного дженерика: List<List<Path>> и List<Path>. В итоге две строки описывали Payment.Paths старым именем, а две следующие — новым. Промах был в самой проверке: я искал Methods.Path и <c>Path</c>, а имя внутри <...> под этот поиск не попадало. Поиск шире дал три совпадения, и заменить надо было ровно одно место из трёх: в PathSet.cs это Path кодека, который остаётся, а в PathStep.cs — намеренная ссылка на старое имя в объяснении, что и почему переименовано. Слепая замена сломала бы оба.
… stops leaving quietly (#124) * feat(client)!: сессия, которая закончилась, теперь об этом сообщает — OnSessionEnded Closes #123. Подписки живут на ноде против одного соединения: уходит соединение — уходят и они, и потребителю нужно переподписаться. Трудность в том, когда именно: соединение может закончиться тремя способами, а объявлялся только первый. ChangeServer не объявлял ничего. Он помечает старую сессию уходящей, из-за чего собственный колбэк закрытия сокета намеренно выходит рано, а единственное уведомление — статус Connecting, тот же, что шлёт первое подключение. Клиент продолжал докладывать Connected, а поток был мёртв навсегда. Воспроизведено трижды в блазор-демо из репозитория: после переключения 0 транзакций и 0 леджеров, при этом ручная переподписка немедленно оживляет поток — значит соединение исправно, потеряна подписка. Быстрое переподключение (пинг-таймаут, обрыв сети) слало только статус RestoringConnection: он говорит, что соединение перестраивается, а не что всё привязанное к прежнему исчезло. Заодно закрыт дефект уровнем ниже, найденный при отладке теста: у цикла приёма был выход, не сообщавший ничего, — его собственное условие while. Обработчик сообщения выполняется на том же потоке, и продолжение запроса, который он завершает, — тоже, поэтому вызывающий, отключающийся сразу после разбудившего его ответа, делает это внутри цикла. Цикл, вернувшись, застаёт отмену уже выставленной и уходит молча. Отмена запаркованного приёма бросает и докладывается штатно — потому дефект выглядел исправным на медленном узле и исчезал на быстром. Теперь этот выход докладывает закрытие, как и все прочие, так что OnDisconnect срабатывает и здесь. Объявление конца делается явно на двух путях намеренного ухода сессии, за которые не может отчитаться ни один колбэк; остальное берёт на себя колбэк закрытия сокета — теперь, когда он выполняется всегда. Защёлка на объекте сессии удерживает одно объявление на сессию: сокет может закрыться сам ровно в тот момент, когда путь ухода ещё не пометил сессию, и тогда о ней сообщили бы дважды и по разным причинам. OnDisconnect не меняется и значит прежнее — сокет закрылся. OnSessionEnded — то единственное, на что нужно подписаться, чтобы знать о необходимости переподписки; причина (ServerChanged, ConnectionLost, UserDisconnected) позволяет отличить смену сервера от отказа и от собственного действия. На неудачную попытку подключения не срабатывает: сессии не было, терять нечего. Это не регресс: тот же сценарий воспроизводится на origin/release (Xrpl 10.12.0.0), до всей работы над потоком в этом релизе. Дефект был молчаливым, и видимым его сделала именно работа над наблюдаемостью потока. Ломающее для внешних реализаций IXrplClient: у интерфейса появился event OnSessionEnded. Xrpl уже на 11.0.0.0, дополнительного подъёма не нужно. Проверка: 1141 юнит-тест и 265 интеграционных на стенде rippled 3.3.0, 0 падений. Четыре мутации, каждую ловит свой тест: снятое объявление в ChangeServer, в быстром переподключении, в OnceClose, и возвращённый молчаливый выход цикла приёма. Защёлка на сессии тестом не покрыта — гонка, ради которой она стоит, из публичного API не воспроизводится. * feat(demo): блазор-демо подписывается на OnSessionEnded и переживает смену сервера Демо восстанавливала подписки по OnDisconnect и по статусу RestoringConnection — и оба при успешном ChangeServer не приходят, поэтому поток после переключения умирал молча. Это и есть #123, найденный именно здесь. Новый обработчик — один сигнал на все способы, которыми соединение может закончиться. Старые два оставлены как есть: они защищены проверкой IsStreamSubscribed, которую новый обработчик снимает первым, так что двойного восстановления не происходит. Проверено вживую: mainnet со включённым потоком → Testnet. 12:02:52.801 Session ended (ServerChanged): Switched to wss://s.altnet... 12:02:53.782 Restoring stream subscriptions... 12:02:53.967 Stream subscriptions restored 12:02:53.980 [Connected] Connected wss://s.altnet.rippletest.net:51233 Порядок ровно тот, что и задуман: конец старой сессии объявлен до того, как поднялась новая. Дальше счётчики росли сами, без ручного вмешательства. Тот же прогон подтвердил, что дублей нет ни на одном пути: на переключении уходящий сокет не добавил лишней строки Disconnected (OnceClose отсекает уходящую сессию), а на пользовательском Disconnect вышло ровно по одному Session ended и Disconnected. * docs(client): селф-ревью — пересчитанные события, порядок срабатывания и стоимость обработчика Найдено при вычитке собственного дифа: - в таблице миграции стояло «15 событий → 16»; на самом деле у IXrplClient их было 16 и стало 17 — посчитано по файлу, а не по памяти; - CHANGES говорил «четырьмя способами», doc интерфейса — «тремя»: одно и то же разными числами. Число убрано там, где оно ничего не несёт, а перечисление осталось; - строка из прежней записи «все 16 событий объявлены на IXrplClient» устарела в том же релизе — число снято, утверждение осталось верным; - в doc события не было сказано главного для потребителя: на смене сервера и быстром переподключении оно приходит до того, как поднимется новое соединение, поэтому переподписываться надо в OnConnected, а не из обработчика. Заодно сказано, что обработчик ожидается и медленный тормозит ровно то переподключение, о котором сообщает; - WaitUntilAsync стоял между тестами — перенесён к остальным вспомогательным членам; у фиксированного ожидания в тесте объяснено, что оно не для первого события, а для возможного второго; - doc SilentOnPingServer говорил «отвечает на обычные запросы»; на деле он отвечает на всё, кроме ping, одним и тем же телом server_info; - лишний $ в строке без интерполяции в демо. Проверялась и гипотеза о взаимоблокировке: обработчик на пути быстрого переподключения выполняется внутри задачи пинга, а Disconnect() её ждёт. Ждёт не бесконечно — WaitForPingToFinishAsync ограничен тремя секундами и после таймаута сбрасывает флаг, так что это задержка, а не тупик; предупреждение в doc не пишется. 1141 юнит-тест, 0 падений. * fix(client): конец объявляется только для сессии, у которой было соединение CI поймал то, чего Windows не показывает: подключение к закрытому порту объявляло конец сессии — по одному разу на попытку, три подряд. Объект сессии создаётся до вызова ws.Connect(), поэтому его существование ничего не говорит о том, состоялось ли соединение. Неудачная попытка всё равно доходит до колбэка закрытия, и он объявлял конец сессии, которой никогда не было, — а потребитель по этому сигналу переподписывается, хотя подписываться было не на что. Сессия теперь помечается поднявшейся в OnceOpen, и объявляется конец только такой. Обещание в doc события («не срабатывает на попытку подключения, которая не удалась») было записано с самого начала — не выполнялась именно реализация. Почему не поймалось локально: доходит ли колбэк закрытия, пока его сессия ещё активна, — вопрос тайминга. На Linux идентификаторы совпадают, и дефект видно; на Windows следующая попытка успевает поставить свою сессию, идентификаторы расходятся, и счётчик равен нулю и с правкой, и без неё. Проверено снятием правки: три прогона подряд announced=0. В тест добавлено утверждение, что попытка действительно провалилась и была доложена (closes=2 и здесь), чтобы он не проходил вхолостую, и в его doc записано, что на Windows он стоит меньше, чем на Linux. 1141 юнит-тест, 0 падений.
… recovered from reports nothing (#125) * fix(client): сдавшийся Connect() сообщает, что не подключился, а не что его отменили Closes #122. Connect() — это две операции, а не одна: само подключение и server_info, который SetNetworkId шлёт сразу за ним. Сокет действительно открывается на мгновение, прежде чем падающий обработчик OnConnected его уронит, поэтому ожидание успевает вернуться успешно, и вызывающий может уже находиться внутри второй операции, когда клиент сдаётся. Путь сдачи шёл через Disconnect(), а тот отклоняет всё в полёте с OperationCanceledException — верно для закрытия, о котором попросил вызывающий, и неверно для отказа. Потребитель, ловящий отмену, чтобы отличить «операцию отменили» от «операция не удалась», получал первое там, где случилось второе. Теперь путь сдачи отклоняет запросы в полёте с NotConnectedException до вызова Disconnect(), так что вызывающий слышит одно и то же, в какой бы из двух операций он ни находился. Флак стал тестом. Утверждение падало на CI примерно раз через раз и ни разу локально за 37 прогонов — потому issue и висел с недоказанным механизмом. Задержанный ответ мока на server_info ставит запрос в полёт наверняка: воспроизведение падало три раза из трёх до правки и проходит после. Мутацией проверено — со снятой правкой тест падает, три прогона из трёх. Обработчик в тесте сначала работает и только потом падает: это и есть настоящий случай (подписка, которая какое-то время выполняется), и без него сокет не успевает побыть открытым, а вызывающий до второй операции не доходит. 1142 юнит-теста, 0 падений. * fix(client): отказ, из которого клиент выкарабкался, больше не доходит до вызывающего Тот же неверный ответ выходил из соседней ветки того же метода, и там он был хуже. Обработчик, который падает один раз и работает со второго, — это ровно тот случай, ради которого ветка повтора и существует. Но снос соединения между попытками отклонял и server_info, который был в полёте у вызывающего, поэтому Connect() бросал исключение, пока клиент благополучно подключался. Замерено зондом: вызывающий получал OperationCanceledException, а IsConnected() через три секунды возвращал True. То есть Connect() докладывал провал там, где всё получилось. Connect() теперь дожидается собственного восстановления клиента и переспрашивает. Проверка «подключён ли прямо сейчас» вместо ожидания не годится: в момент отклонения сокет только что снесли, и ответ будет «нет», как бы хорошо ни шло восстановление. Ожидание же заканчивается либо возвратом соединения, либо NotConnectedException, если клиент сдался, — это и есть единственный случай, когда вызывающий действительно не подключился. Попыток больше одной: ожидание может вернуться и во время самого сноса, пока старый сокет ещё формально открыт, и тогда запрос уходит в уже умирающий сокет. Потолок — число перестроений соединения: каждый повтор здесь отвечает одному сносу, а чаще клиент снести соединение не может, прежде чем сдастся. Мутациями проверено обе части: без повторов тест падает 2 из 2, с повтором без ожидания подключения — 3 из 3. Селф-ревью: убран дубль комментария в connection.cs, оставшийся от цикла мутации; снят лишний именованный аргумент в Math.Max; поправлен порядок using в моке. Проверка: 1143 юнит-теста и 265 интеграционных на стенде rippled 3.3.0, 0 падений. * docs(client): Connect() описывает свой контракт, а не «connect to the server» У метода поменялось поведение, а в интерфейсе стояла одна строка, и в реализации — пустой <returns/>. Потребителю нужно знать три вещи, ни одна из которых оттуда не следовала: что отказ обработчика OnConnected, из которого клиент выкарабкался, до вызывающего не доходит; что NotConnectedException означает «клиент сдался»; и что OperationCanceledException теперь означает ровно отмену, о которой попросил сам вызывающий, и ничего больше. Реализация переведена на <inheritdoc/> — описание живёт там, где его читают. Замечание пришло из проверки покрытия doc-комментариев в ревью CodeRabbit; взято то, за чем стояла настоящая дыра, дописывать комментарии тестовым хелперам ради процента не стал. 1143 юнит-теста, 0 падений. * style(tests): комментарий по-русски в коде, лишняя минута висящей задачи и формулировки утверждений Селф-ревью по своему же дифу: - в TestGivingUpWithARequestInFlightStillReportsNotConnected комментарий был написан по-русски, хотя правило репозитория — комментарии только на английском. Переписан, заодно сказано прямо, что без работающего-перед-падением обработчика тест проходит вхолостую; - задержка ответа мока была 30 секунд при сдаче за полсекунды: фоновая задача висела полминуты после каждого прогона. Пять секунд — те же десятикратный запас; - ServerInfoResult стоял между MyTestInitialize и MyTestCleanup, разрывая два метода жизненного цикла; перенесён к остальным вспомогательным; - два последних утверждения назывались «предусловием предыдущего», хотя проверяют другое — что прогон действительно был тем сценарием, о котором тест, а не тихо ничего не сделавшим подключением. Формулировки исправлены, в сообщение добавлено фактическое число вызовов обработчика; - cref на NotConnectedException приведён к одному виду в обоих блоках. Проверено, что в добавленных строках дифа кириллицы не осталось: 0. 1143 юнит-теста, 0 падений; класс целиком — три прогона подряд зелёные.
* feat(models): memo, который узел отвергнет, отвергается до подписи Closes #119. rippled ограничивает сериализованный массив Memos 1024 байтами в passesLocalChecks -> isMemoOkay. Это локальная проверка: транзакция не ретранслируется, в леджер не попадает, комиссия не списывается — потребитель просто узнаёт, уже собрав, автозаполнив и подписав её, что узел её не возьмёт, и в ответе не сказано, какое поле виновато. SDK проверял структуру массива и никогда — размер. MemoRules.Validate зовётся из XrplWallet.Sign, куда приходит любой путь подписи в SDK. Намеренно не из Validation.Validate: его продакшн-код не зовёт нигде, и правило осталось бы правилом, которое никто не выполняет. Длина считается так же, как её считает узел, — сериализацией массива через кодек, а не подсчётом байт руками: маркер начала объекта, поля с префиксами длины, маркер конца, маркеры самого массива не в счёт. На практике в memo без других полей помещается 1019 байт MemoData. Лимит на массив целиком, поэтому разложить содержимое по нескольким memo не помогает — об этом сказано в тексте исключения, потому что это первое, что приходит в голову попробовать. MemoType и MemoFormat обязаны декодироваться в символы, разрешённые RFC 3986 в URL; исключение называет виновный байт. На MemoData ограничение не распространяется — в этом и смысл поля. Два из пяти правил узла здесь намеренно не повторяются: посторонний член внутри Memo и значение не в hex уже отвергает кодек, причём лучше — он называет виновное поле. Первая редакция проверки сообщала о них раньше кодека и худшими словами; это поймали два существующих теста. Проверено против настоящей ноды в обе стороны: на стендалон-стенде memo ровно в лимит доходит до леджера, а на байт больше отвергается с «The memo exceeds the maximum allowed size». Константа, взятая из чтения исходника, может ошибаться в сторону строгости так же легко, как в сторону мягкости, и рассудить может только узел. Проверка: 1151 юнит-тест и 267 интеграционных, 0 падений. Три мутации, каждую ловит свой тест: сдвинутый лимит, снятый вызов из Sign, правило, распространённое на MemoData. * fix(wallet): проверка memo стоит на всех входах подписи, а не только на Sign Ревью CodeRabbit нашло две настоящие дыры, обе подтверждены падающими тестами до правки. Sign — не единственный публичный вход в подпись. SignAsBatchPart, SignAsSponsor и SignAsLoanCounterparty подписывают сами по себе, и мультибатч-отправка в самом SDK (Submit.cs) зовёт первый из них напрямую — то есть batch-путь проверку не проходил вовсе. Утверждение в теле PR, что «любой путь подписи приходит в Sign», было неверным. Проверка вынесена в общий GuardMemos и стоит на всех четырёх входах, принимающих словарь; типизированные перегрузки конвертируют и делегируют им, так что покрыты все восемь. MemoType или MemoFormat, который не строка, ронял GetValue<string> с InvalidOperationException изнутри System.Text.Json — ни то исключение, которого ждёт вызывающий SDK, ни сообщение, называющее поле. Теперь нестроковое значение оставляется кодеку, который его отвергает и говорит, какой член неверен, — тот же принцип, что и для двух правил, которые кодек уже держит. Четыре новых теста: три на прямые входы подписи, один на нестроковый MemoType. Все четыре падали до правки. Проверка: 1155 юнит-тестов и 267 интеграционных на стенде rippled 3.3.0, 0 падений.
* fix(models)!: четыре места, где модель говорила неправду Closes #128, closes #129, closes #134, closes #135. Ни одно из четырёх не падало громко: перевёрнутый предикат возвращает bool, поле, которого нет в протоколе, прекрасно сериализуется, сопоставление с не той половиной пары типов компилируется и молча ничего не находит. Цена — на стороне потребителя: отказ узла или, хуже, успешная транзакция не о том. Currency.IsMPTToken отвечал ровно наоборот (#128): не хватало отрицания, поэтому любая сумма, которая НЕ многоцелевой токен, объявлялась им, а единственный вид, для которого это верно, — не объявлялся. Внутри SDK метод не вызывается, поэтому ошибку нечему было проявить. NFTokenAcceptOffer больше не объявляет NFTokenID (#129). В transactions.macro у этой транзакции ровно три своих поля, и сам SDK об этом знал — в TxFormat поле закомментировано с пометкой «no need this field». Убрано, а не помечено устаревшим: тот же чистый разрыв, что и с Models.Path. ValidateNFTokenAcceptOffer отвергает одно и то же предложение с обеих сторон (#134). rippled сверяет владельца каждого предложения с отправителем ПО ОТДЕЛЬНОСТИ — два блока в preclaim читаются как выбор между прямым и посредническим режимом, но выбором не являются, — поэтому одно предложение, названное дважды, даёт сравнение счёта с самим собой: tecCANT_ACCEPT_OWN_NFTOKEN_OFFER, в леджере, с комиссией. Одно сравнение ловит это, ничего не спрашивая у узла. Там же в doc записано, что посредническая сделка требует трёх разных счетов, — по C++ это читается ровно наоборот. Валидатор остаётся опциональным: в отличие от правил memo, Validation.Validate выполняется только если его позвал потребитель. Разбор истории задокументирован (#135): из леджера приходит ответная половина пары, поэтому summary.Transaction is NFTokenCreateOffer компилируется и не срабатывает никогда — а выглядит как поломка разбора ответа. Обе половины реализуют общий I-интерфейс. Написано на TransactionSummary.Transaction, где возникает путаница, и в README. Попутная находка, зафиксированная тестом: инвариант «обе половины пары реализуют общий I-интерфейс» держится для 77 пар из 82. Пять ConfidentialMPT* не имеют интерфейса вовсе — ни запрос, ни ответ. Они перечислены в тесте списком, так что шестой такой тип упадёт здесь, а не найдётся потребителем, у которого сопоставление молча ничего не нашло. Проверка: 1161 юнит-тест и 267 интеграционных на стенде rippled 3.3.0, 0 падений. Четыре мутации, каждую ловит свой тест, включая снятие одного имени из списка исключений — тест на пары не проходит вхолостую. * docs: примеры в README и doc-комментарии компилируются, если их скопировать Ревью CodeRabbit: обе строки примера объявляли шаблонную переменную с одним именем offer в одной области. В C# шаблонная переменная из условия if живёт до конца объемлющего блока, поэтому второе объявление — CS0128. Проверено сборкой отдельного проекта: пример действительно не компилируется, а исправленный — компилируется. Для примера, весь смысл которого в том, чтобы его скопировали, это существенно: читатель получает ошибку компиляции ровно там, где ему объясняют, как избежать молчаливого несовпадения. Имена разведены: request для той половины, которая никогда не совпадёт, offer — для верной. * docs: документация перестаёт утверждать то, что опровергает собственный тест Селф-ревью по своему же дифу, пять находок. Главная: и README, и doc-комментарий на TransactionSummary.Transaction, и CHANGES писали «обе половины КАЖДОЙ пары реализуют общий I-интерфейс» — при том что тест в этом же PR измеряет обратное и перечисляет пять ConfidentialMPT*, у которых интерфейса нет ни с одной стороны. В CHANGES это противоречие стояло внутри одного пункта: первая фраза утверждала абсолют, следующая называла исключения. Везде переписано на то, что верно, с прямым указанием пяти. Doc у IsMPTToken рассказывал историю дефекта («не хватало отрицания…») в remarks, который потребитель читает в подсказках вечно. История уже есть в CHANGES и в коммите; в remarks теперь сказано то, что нужно вызывающему: признак MPT — идентификатор выпуска, и он исключает XRP и выпущенную валюту. Абзац про посреднический режим стоял внутри summary у NFTokenBrokerFee — перенесён в remarks, где ему и место. TestUNFTokenAcceptOfferStillCarriesItsOwnThreeFields проверял два поля из трёх при таком названии — добавлено NFTokenBrokerFee. Оттуда же убрано утверждение, что NFTokenID нет в JSON: null-свойства и так не сериализуются, поэтому оно прошло бы и до удаления поля. Утверждение, которое не может упасть, хуже отсутствующего — оно обещает покрытие, которого нет. Отсутствие поля доказывает рефлексионный тест, и в doc теперь сказано, почему именно он. 1161 юнит-тест, 0 падений.
…on it is (#137) * fix(models): транзакция, объявленная интерфейсом, сериализуется как транзакция Closes #127. System.Text.Json выбирает конвертер по ОБЪЯВЛЕННОМУ типу, а [JsonConverter] стоял на абстрактном классе, а не на ITransactionRequest. Переменная, объявленная интерфейсом, поэтому и писалась как интерфейс: ни одного поля собственного типа транзакции — платёж без Amount и без Destination, — и ни исключения, ни предупреждения. Работало ровно до тех пор, пока всё шло через XrplJsonOptions.Default, чей список конвертеров компенсирует отсутствующий атрибут. Тот, кто сериализовал своими JsonSerializerOptions, получал пустую форму и узнавал об этом по отказу узла или, хуже, по успешной транзакции не о том. Тем же одним местом лечатся ещё два симптома: "TransactionType":16 вместо "Payment" (конвертер enum'а тоже объявлен на свойстве класса) и SigningPublicKey с TransactionSignature на проводе вместо SigningPubKey и TxnSignature (атрибуты [JsonPropertyName] тоже на классе). Атрибут объявлен на ITransactionRequest и ITransactionResponse в дополнение к классам. Больше ничего не меняется: конвертер и так пишет через value.GetType(), поэтому пишет ровно то, что написал бы конкретный тип. Пять тестов, четыре из которых падали до правки: одинаковая сериализация через интерфейс и через конкретный тип, наличие полей платежа, имя типа транзакции вместо числа, протокольные имена полей подписи. Пятый — что путь через настройки SDK, который никогда не был сломан, таким и остался. Мутация со снятием атрибута роняет те же четыре. Проверка: 1160 юнит-тестов и 267 интеграционных на стенде rippled 3.3.0, 0 падений. * test(json): круговой разбор через интерфейс и утверждение, которое не устареет Селф-ревью по своему же дифу. Тесты покрывали только запись, хотя атрибут влияет и на чтение: раньше разбор в переменную типа ITransactionRequest чем-либо, кроме настроек SDK, не имел конвертера, за который взяться, и не мог построить интерфейс. Добавлен круговой тест — он падает вместе с остальными при снятии атрибута, то есть проверяет ту же правку с другой стороны. Утверждение «в JSON нет "TransactionType":16» зашивало числовое значение enum'а: в день, когда оно изменится, утверждение перестало бы проверять что-либо, продолжая проходить. Заменено на проверку пары "TransactionType":"Payment" — она же закрывает и вторую дыру: отдельная проверка присутствия "Payment" где-нибудь в тексте прошла бы и в случае, когда поле хранит число, а имя встретилось в другом месте. У ITransactionRequest был remarks без summary, и семь строк remarks дословно повторялись на обоих интерфейсах. Теперь у запроса — своё summary и полный разбор причины, у ответа — короткая ссылка на него. 1166 юнит-тестов, 0 падений.
…ых окна (#139) TestUTransactionStreamAttachFrameRetainsNoMoreThanTheFrame падал на CI и уронил очередь слияния PR #137, а затем прогон PR #138 — ни один из них этого файла не касался. Перезапуск той же джобы без единой правки прошёл, чем причинность и установлена. Тест брал два измерения всей кучи в двух отдельных окнах — проход с привязкой кадра и проход без неё — и вычитал одно из другого. Всё, что сдвигало кучу между проходами, попадало прямо в ответ. На Linux сдвигало, и больше чем на мегабайт: локально без кадра 1616, с кадром 1616, marginal 0 CI (#137) без кадра 978, с кадром 1616, marginal 638 CI (#138) без кадра 1078, с кадром 1616, marginal 538 Значение «с кадром» одинаково везде. Плавало базовое измерение, и его дрейф докладывался как стоимость AttachFrame при бюджете 300 байт. Теперь экземпляры создаются до открытия окна, а внутри окна нет ничего, кроме вызовов AttachFrame на тех же объектах. Измеряется ровно то, что AttachFrame выделяет, — то есть то, ради чего тест назван. Сравнение убрано, а не бюджет расширен. Проверено, что тест сохранил силу: с AttachFrame, копирующим кадр на экземпляр, он докладывает 768 B/экземпляр и падает — то есть регресс, ради которого он существует, по-прежнему виден. Пять прогонов исправленной версии подряд дают 0 B. Родственный TestUEnvelopeRetainsNoMoreThanTheFrame проверен и не трогается: он берёт одно абсолютное измерение, а не разность двух, и его граница 6144 байта против сигнала в 65 536 — запас на три порядка. На CI он не падал. 1167 юнит-тестов, 0 падений.
* feat(sugar): отказ отправки приходит типизированным — TransactionFailedException Closes #131. SubmitAndWait бросал голый RippleException, всё содержимое которого — фраза. Отличить tecINSUFFICIENT_PAYMENT от tecEXPIRED можно было только разбором текста, а классы кодов означают совершенно разное: tem — запрос неверен и его надо чинить, tec — применён и комиссия списана, ter — может сработать позже. Хеша не было вовсе, а он нужен именно после tec: транзакция лежит в леджере, и показать её — первое, что хочется сделать. Новый тип несёт EngineResult, Hash, Result (проверенную транзакцию с метой) и ReachedLedger. Ничего не ломается: тип производный от RippleException, сообщение оставлено байт в байт. Четыре интеграционных теста в этом репозитории сверяют этот текст дословно и не потребовали ни единой правки — то же утверждение с другой стороны. ReachedLedger читается из кода результата, а не из наличия Result. Это нашёл интеграционный тест: тот же отказ докладывается в один из двух моментов, смотря успел ли закрыться леджер до первого опроса, — после проверки, с метой, или раньше, из предварительного ответа узла, когда есть только код и хеш. tec попал в леджер в обоих случаях, и кто выиграл гонку — не то, о чём должен думать вызывающий. Поэтому Result может быть null при ReachedLedger = true, а Hash есть всегда; в тесте это зафиксировано, а не обойдено ожиданием. Проверка: 1164 юнит-теста и 269 интеграционных на стенде rippled 3.3.0, 0 падений. * docs(changes): запись про TransactionFailedException * test(sugar): tec без сводки — случай, ради которого правка и делалась, теперь проверен Селф-ревью по своему же дифу, две находки. Мутация показала дыру: подмена ReachedLedger обратно на «Result is not null» проходила все три юнит-теста. Тот случай, который и заставил менять реализацию, — tec БЕЗ сводки — не был покрыт ни одним из них: tec со сводкой и tem без сводки дают одинаковый ответ при обоих прочтениях. Добавлен тест ровно на эту комбинацию, и он единственный ловит мутацию. Отдельно важно, что этот случай нельзя было оставить интеграционному тесту: там ветка зависит от того, успел ли закрыться леджер до первого опроса, то есть проверялась бы через раз. Второе: комментарий на предварительном пути утверждал «refused before a ledger, nothing was charged, nothing to show» — то самое, что я опроверг следующей же правкой, когда сделал ReachedLedger читать код. Переписан по существу: ветка срабатывает, когда транзакция ещё не проверена, а предварительный ответ уже отказ; tem и tef действительно не доходят до леджера, а tec дошёл и комиссия списана — просто на этот момент он ещё не проверен. Проверено также, что соседних мест нет: оба входа SubmitAndWait сходятся в одну WaitForFinalTransactionOutcome, в отличие от истории с Sign в #126, где guard пришлось ставить на четыре входа. 1165 юнит-тестов, 0 падений.
* feat(methods): nft_info и nft_history — команды Clio, без которых владельца NFT не узнать Closes #132. Ни той, ни другой не было в моделях, и заменить их на rippled нечем. Владельца нельзя взять из nft_sell_offers, хотя это первое, что приходит в голову: продажа токена не убирает предложения о нём из леджера, поэтому предложения прежнего владельца продолжают возвращаться и после того, как их нельзя принять, а у нового владельца предложений обычно нет вовсе — ровно то состояние, в котором токен оказывается сразу после покупки. Имена полей взяты из самих обработчиков Clio, а не из документации, и одно из них расходится: Clio отдаёт nft_serial, а в его же исходнике помечено, что документация называет это nft_sequence. Тест закрепляет то имя, которое приходит на провод; мутация с подменой на документационное его роняет. Записи истории — та же форма, что возвращает account_tx, поэтому их читает TransactionSummary, вместе с конвертами API v1 и v2, а не второй тип, который пришлось бы держать в согласии с теми же конвертами rippled. Разбирать их следует через I-интерфейсы, как и любую транзакцию из леджера, — на это есть утверждение в тесте. Обе команды только у Clio. Обычная нода rippled отвечает unknownCmd, и это доходит до вызывающего обычным RippledException с этим кодом, так что тот, кому нужно работать с обеими, может его распознать и откатиться на свой обход. На это есть интеграционный тест против того самого стенда rippled, на котором идёт набор. Проверка: 1176 юнит-тестов и 271 интеграционный на стенде rippled 3.3.0, 0 падений. * test(methods): проверка, что ничего из присланного Clio не пропущено Селф-ревью по своему же дифу: тесты проверяли то, что я смоделировал, и ничего не говорили о том, не пропустил ли я чего-то. Одиннадцать верно прочитанных свойств не исключают двенадцатого, тихо осевшего в UnknownFields. В репозитории для этого уже есть планка, установленная работой над #106: поле считается смоделированным, только если оно и объявлено свойством, и исчезло из UnknownFields. Утверждение добавлено в оба теста разбора, и оно называет виновное поле, а не просто падает. Проверено мутацией — но не с первой попытки. Удаление свойства из модели не годится: тест на него ссылается, сборка ломается, и прогон идёт на устаревшем бинарнике, показывая ложный успех. Годная мутация — добавить в тело ответа поле, которого модель не знает, как это сделает Clio, добавив что-нибудь в следующей версии. Тогда падает ровно новое утверждение: nft_info fields the model does not declare: nft_future_field 1176 юнит-тестов, 0 падений. * docs: фраза про устаревшие предложения говорила обратное задуманному Ревью CodeRabbit: «offers keep being returned long after they can be accepted» — ровно наоборот. Смысл в том, что они возвращаются и после того, как принять их УЖЕ НЕЛЬЗЯ: продажа токена не убирает их из леджера. Указано было на CHANGES.md, но фраза оказалась в трёх местах и написана двумя способами: в doc-комментарии теста верно, в doc модели и в CHANGES — наоборот. То есть публичная документация свойства Owner объясняла причину существования команды через утверждение, обратное истине. Исправлены оба неверных места, а не только названное. 1176 юнит-тестов, 0 падений.
* feat(sugar): AMM deposit and withdrawal arithmetic the node agrees with The SDK offered no way to work out what an AMMDeposit would credit before submitting it, so consumers reached for the widely quoted T*(sqrt(1 + b*(1 - f/2)/B) - 1). That is the right formula with the fee applied loosely: exact wherever there is no fee, which is what makes it hard to catch, and at a 1% fee it credits 0.41244*T where the node credits 0.41213*T - out by 0.08%, always promising more tokens than arrive. AmmMath takes equations 3 and 7 from rippled's AMMHelpers.cpp verbatim. The two are not symmetric: lpTokensIn multiplies by the fee where lpTokensOut multiplies by 1 - fee, and swapping them survives a round-trip inequality, so the zero-fee identity is pinned by its own test. Two things outside the formulas decide whether an estimate matches, and both are documented where a caller will meet them. The auction slot holder trades at DiscountedFee, a tenth of the pool's fee, and AMMCreate hands the slot to the pool's creator - estimating at the pool's fee was out by 0.23% in the integration test, three times the error this replaces, with every equation right. And amm_info has to be read immediately before the calculation, or the drift reads as an arithmetic error. A fee outside rippled's kTradingFeeThreshold is refused rather than answered: in units of 1/100 000 a caller reaching for basis points is out by a factor of ten, and at 5000 every intermediate value stays finite and a plausible wrong number comes back. decimal throughout, with a Newton square root, because Math.Sqrt carries 15 significant digits against decimal's 28 and the root is where the precision is needed. Verified against a node, not only against the source: deposits and withdrawals on the standalone stand agree with what was actually credited to 15 significant digits. Closes #133 * test(amm): the node comparison enforces the precision it reports The changelog claimed agreement to 15 significant digits while the tests enforced 1e-5, so a formula subtly wrong at 1e-7 would have passed both. Tightened to 1e-9: still six orders of margin over the measured 3.4e-15 and 1.6e-15, and five over the 8e-4 error being guarded against. Not tighter, and the gap is now written down. The figure compared is the difference of two reported LP token balances, so the last of STAmount's 15 significant digits goes to cancellation before the comparison happens; asserting near the measurement would buy brittleness rather than coverage. Also completes a changelog sentence that read as truncated. * feat(sugar): the swap, and the inverse of each equation AmmMath answered "what will this deposit be worth" and "what will this withdrawal cost" and nothing else. Three questions a caller actually asks were missing, and two neighbouring projects had already written their own answers to them - one of which was wrong in precisely the way this class exists to fix. 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 a different number from taking it off the output, and the natural mistake. Both are written in the rearranged form: the node's own expression subtracts two nearly equal numbers for a small swap and loses digits to the cancellation, while poolOut*x/(poolIn + x) has nothing to cancel. SingleAssetDepositForLPTokens and SingleAssetWithdrawForLPTokens are equations 4 and 8 - what an AMMDeposit carrying LPTokenOut costs, and what an AMMWithdraw carrying LPTokenIn returns. Equation 4 runs through a quadratic, so it is pinned by composition with equation 3 rather than by a hand-computed figure; likewise 8 against 7, and SwapAssetOut against SwapAssetIn. Mutations to each break at least one of those identities. Units are the caller's and nothing converts between them, now said out loud in the class docs because it bit while writing the tests: 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. Verified against the node. The swap test routes a partial payment through the pool from a second holder - the one account in the class that does not hold the auction slot, so it is also the only case that exercises the pool's own fee rather than the discount. Relative errors: 9.7e-17 on the swap, 6.2e-14 and 3.8e-14 on equations 4 and 8. * docs(amm): say what the class now covers, and that the quadratic is safe Self-review of the additions. The class summary still described a class that only did deposits and withdrawals; it now does swaps and reads each equation backwards too. SolveQuadratic hands Sqrt an unguarded radicand, which rippled's own solveQuadraticEq does as well, and a reader has no way to tell whether that can throw. Swept across every fee up to the cap and token ratios from 1e-6 to 1000, the discriminant never falls below 1 - so it cannot, and that is now written down rather than left to be rediscovered. The swap test held SendXrp and SendDrops as two independent constants, in a test whose whole subject is that mixing those two units produces a number looking like a broken formula. The second now derives from the first. Fixes a typo in a test method name: TheePool.
The section going out in this release was still titled "Unreleased". Merging dev into release with it that way leaves the 11.0.0.0 tag pointing at a changelog whose top section carries neither a version nor a date, and by then it is too late to fix for that tag. Stamped the way every previous release was, and at the same point in the process: on dev, in an ordinary PR, before promotion - "## 10.12.0.0 08/16/2026" arrived that way in #94. The date follows the same convention as the headings above it, which track the commit date in UTC. Nothing else changes. CHANGES.md is not packed into any NuGet package, so this affects the repository and the tag rather than what gets published.
Platonenkov
enabled auto-merge
August 26, 2026 01:38
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Promotion of
devintoreleasefor 11.0.0.0. Not ready to merge — see Before merging below.Why the major
This release stops the SDK misrepresenting what a node sent, and that could not be done compatibly. A deliberate break with no
[Obsolete]bridges, the same policy asPath.TypeHexin 10.11.0.0.CHANGES.mdopens with a Migration at a glance table of everything that will not compile.The measured problem, on a live ten-entry
account_txatapi_version = 2: 156 fabricated members and 28 dropped. After the five levels of raw-response work: 0 fabricated, and every remaining drop is named with a reason and guarded by a test.Package versions
XrplXrpl.BinaryCodecXrpl.AddressCodecXrpl.KeypairsThe two base packages are left behind deliberately, per
CLAUDE.md: they are consumed byProjectReference, soXrplbuilt at 11.0.0.0 keeps depending on the published 10.9.0.0 of each.git diff --stat origin/release...origin/dev -- Base/confirms onlyXrpl.BinaryCodecwas touched.What is in it
18 merged PRs. Grouped by theme rather than by order:
Raw responses, all five levels (#102, #116, #120, #121, #115) — a consumer could not get the text a node actually sent, and re-serializing the typed model differed from the original in both directions. 43 client members now return
XrplResponse<T>; unknown members are refused at any depth on the signing path;Models.PathbecomesCommon.PathStep.Streams (#108, #109, #111, #114, #124, #125, #139) — events on
IXrplClient, a drop counter and a queue size for evicted messages, one stream path across platforms, a queue that exists before the consumer subscribes,OnSessionEnded, and a receive loop that no longer exits quietly.Things the model said that were not so (#136, #137, #126, #138) —
Currency.IsMPTTokenanswered the exact opposite;NFTokenAcceptOffer.NFTokenIDwas a property the protocol has no field for; a transaction declared as its interface serialized as the empty interface; a memo a node will refuse is now refused before signing; a failed submission arrives asTransactionFailedExceptioncarryingEngineResult,Hash,ResultandReachedLedger.New surface (#140, #141) —
nft_infoandnft_history, the two Clio commands NFT work needs; andXrpl.Sugar.AmmMath, AMM arithmetic transcribed from rippled'sAMMHelpers.cpprather than from the approximation that circulates.Verification
Run locally on this exact tree:
Every formula in
AmmMathwas checked against a node, not only against the source: relative errors from 9.7e-17 (swap) to 6.2e-14 (equation 4).Before merging
CHANGES.mdstill says## Unreleased. It needs stamping as## 11.0.0.0 08/26/2026. Past practice stamps this ondevin an ordinary PR —## 10.12.0.0arrived that way in perf(client): убрать двойной разбор ответа и две UTF-16 копии сообщения (10.12.0.0) #94 — so this wants a small PR intodevbefore promotion, not a commit here.release, which publishes to nuget.org. That is irreversible; a version number cannot be reused. Admin call, not mine.releaseare not behind the merge queue), so wait for them rather than relying on the local run above.