From 55930624c66a41308958a897907c29041a2ca198 Mon Sep 17 00:00:00 2001 From: "xrpl-release-watch[bot]" <308043467+xrpl-release-watch[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:14:44 -0300 Subject: [PATCH 01/14] ci: bump standalone stand to rippled 3.2.1 (#73) Co-authored-by: github-merge-queue --- .ci-config/docker-compose.ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci-config/docker-compose.ci.yml b/.ci-config/docker-compose.ci.yml index bf42c40a..edd8e90a 100644 --- a/.ci-config/docker-compose.ci.yml +++ b/.ci-config/docker-compose.ci.yml @@ -1,6 +1,6 @@ services: xrpld: - image: xrpllabsofficial/xrpld:3.2.0 + image: xrpllabsofficial/xrpld:3.2.1 container_name: rippled-service command: ["-a", "--start"] ports: From e63e9ef84627d18e048b9659ed3a7b1ef6330f3f Mon Sep 17 00:00:00 2001 From: Aleksandr <44946855+Platonenkov@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:00:21 -0300 Subject: [PATCH 02/14] =?UTF-8?q?fix(client):=20=D0=B8=D1=81=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B8=D0=B7=20OnConnect?= =?UTF-8?q?ed=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=83=D0=B1=D0=B8=D0=B2=D0=B0=D0=B5=D1=82=20=D0=BA=D0=BB=D0=B8?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=20=D0=BD=D0=B0=D0=B2=D1=81=D0=B5=D0=B3=D0=B4?= =?UTF-8?q?=D0=B0=20(10.10.1.0)=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(client): исключение из OnConnected больше не убивает клиент навсегда (10.10.1.0) Connection.OnceOpen ловил любое исключение потребительского обработчика OnConnected и звал Disconnect() - пользовательский путь отключения: _permanentlyDisconnected = true плюс ClearReconnectState(). После этого клиент мёртв навсегда: реконнект-цикл не перезапускается, новых сокетов не открывается, OnConnected больше не поднимается, а любой запрос падает с NotConnectedException. Наружу при этом не выходило ничего - ни события, ни лога. Триггер - самый штатный сценарий: перезапуск ноды. OnConnected - типовое место восстановления подписок (SDK их после реконнекта не восстанавливает), а нода принимает TCP раньше, чем начинает отвечать на запросы, поэтому первый subscribe уходит в RequestTimeout и падает. В проде так встал флот ботов - по четыре часа тишины после обновления ноды. Теперь сбой обработчика трактуется как отказ СОЕДИНЕНИЯ, а не как воля пользователя: сокет сносится, дальше работает обычный реконнект с экспоненциальным бэкоффом, флаг постоянного отключения не ставится. Защита от вечного цикла: OnceOpen чистит реконнект-состояние до вызова обработчика, поэтому счётчик попыток самого цикла обнуляется на каждом успешном TCP-коннекте и сойтись не может. Подряд идущие сбои обработчика считаются отдельно (_connectHandlerFailures, сбрасывается при успешном прогоне обработчика, в Connect() и в ChangeServer()); при достижении MaxReconnectAttempts с StopAfterMaxAttempts клиент осознанно сдаётся - сразу понятный NotConnectedException вместо пятиминутного молчания. Причина теперь наблюдаема: исключение поднимается через OnError с errorMessage = "connectHandlerError" и через OnConnectionStatus. Попутно: WebSocketClient.SendMessageAsync (async void, вызывается без await) больше не глотает ошибку отправки молча - она трассируется. Поведение не меняется, запрос по-прежнему ограничен RequestTimeout, но причина перестала быть невидимой при диагностике. TestUOnConnectedHandlerFailure закрывает все четыре свойства против mock rippled: восстановление после разового сбоя, работоспособность клиента после восстановления, отчёт через OnError и остановка вечно падающего обработчика. * fix(review): замечания CodeRabbit к PR #72 1. Детальная причина отказа терялась. SetConnectionState после Disconnect() - no-op: Disconnect() уже перевёл состояние в Disconnected, а SetConnectionState уведомляет только при смене состояния. Подписчик видел "Disconnected by user request." вместо причины. Уведомление перенесено перед Disconnect(). 2. WaitForConnectionAsync проверял _permanentlyDisconnected один раз на входе и никогда - внутри цикла ожидания. Ждущий в Connect() вызывающий досиживал весь ConnectionAcquisitionTimeout (по умолчанию 5 минут) и получал общий TimeoutException вместо реальной причины. Проверка перенесена в цикл. 3. Личность сокета при разборе. WebSocketClient.Connect вызывает OnConnect без await, поэтому connect-лок отпускается, пока обработчик ещё выполняется, и к моменту разбора ws может указывать уже на новый сокет. Закрывается ровно тот сокет, для которого падал обработчик; ws обнуляется, только если всё ещё ссылается на него; если сокет уже не текущий - реконнект-состояние принадлежит новому соединению и не трогается. При этом тест на вечно падающий обработчик вскрыл гонку в самом фиксе: проверка "цикл реконнекта уже работает" гонится с выходом этого цикла - он прерывается сразу, как сокет отрапортовал Open, то есть ДО того, как обработчик успел упасть. Проигрыш гонки оставлял клиента без реконнекта - ровно тот клин, который правится. Вместо проверки путь теперь безусловно забирает владение: гасит текущий цикл и запускает новый, а пришедший позже OnceClose видит живой цикл и корректно отступает. Побочно: восстановление стало занимать сотни миллисекунд вместо десятков секунд. 4. Ошибка отправки: Debug.WriteLine вырезается без DEBUG, которого нет в релизной сборке. Мёртвый колбэк ошибки WebSocketClient (его никто не звал и не подписывал) теперь доносит исключение до Connection.OnError с errorMessage = "socketSendError". Только уведомление: неудачная отправка сама по себе не означает потерю соединения, реконнект не запускается, запрос по-прежнему ограничен RequestTimeout. Отклонено: подъём версий Xrpl.AddressCodec / Xrpl.BinaryCodec / Xrpl.Keypairs до 10.10.1.0. Эти пакеты в этом PR не менялись, версионируются независимо и уже расходятся с основным на NuGet осознанно (Xrpl 10.10.0 против base 10.9.0; так же было в 10.9.1.0 и 10.10.0.0). Публикация идёт с --skip-duplicate, так что неизменные пакеты просто пропускаются, а подъём выложил бы побайтово те же артефакты под новым номером. Тест TestPermanentlyFailingOnConnectedHandlerStops усилен: теперь проверяет, что ожидающий вызывающий разблокируется именно NotConnectedException. * fix(review): вторая порция замечаний CodeRabbit к PR #72 1. Реконнект-цикл писал в чужую сессию. StopReconnectLoop() отменяет токен, но не дожидается цикла, поэтому снятый цикл мог добраться до тела или хвоста уже после того, как установлена замена, и обнулить _reconnectMode живого цикла, сбросить его _reconnectAttempts или выбросить его _reconnectCts. Дефект существовал и раньше (RetireCurrentSessionAndReconnectAsync снимает циклы ровно так же), но путь сбоя OnConnected делает его гораздо более достижимым. ReconnectLoopAsync теперь принимает CancellationTokenSource, которым владеет, и трогает общее состояние, только пока этот источник остаётся активным. 2. Mock-сервер в тестах не останавливался: CreateMockRippled.Start() создавал Server (его конструктор поднимает слушателя) и терял ссылку. Добавлен Stop(), TestUOnConnectedHandlerFailure зовёт его в TestCleanup. * fix(client): ChangeServer на неподнятый сервер больше не убивает клиент Тот же класс клина, что и основной фикс PR, но другой путь. Найдено при прогоне Blazor-демо: переключение селектора сети на выключенную ноду. ChangeServer ставил ГЛОБАЛЬНЫЙ _isIntentionalDisconnect = true, чтобы отфильтровать поздние колбэки уходящего сокета, а сбрасывался этот флаг только в OnceOpen. Если новый сервер не поднимался, OnceOpen не выполнялся никогда: OnConnectionFailed читал отказ НОВОГО соединения как отключение по воле пользователя, писал "Connection closed permanently.", не запускал реконнект-цикл, и дальше всё - включая сам ChangeServer - падало с вводящим в заблуждение "No connection attempt in progress. Call Connect() first.". Поднятие сервера потом ничего не меняло: клиент был мёртв. Поздние колбэки теперь фильтруются исключительно по-сокетному отслеживанием, которое здесь и так уже было (_userInitiatedSockets плюс собственный флаг сокета, выставляемый в RetireOldSessionAsync) - ровно так же, как всегда делал путь ping-timeout / network-drop; в его коде даже висит комментарий, предостерегающий от глобального флага именно по этой причине. Дополнительно флаг явно гасится на входе, чтобы ChangeServer после пользовательского Disconnect() не подавлялся оставшимся от него значением. Дефект существовал до этого PR - проверено дважды: ни одна из строк этого пути им не менялась, и одинаковый диагностический тест на чистом dev (f17fc11) даёт байт-в-байт тот же вывод. TestUChangeServerFailure закрывает оба случая: клиент доходит до нового сервера, когда тот появляется, с предшествующим Disconnect() и без него. Оба теста падают на dev и проходят с фиксом. * fix(review): гонка старта и остановки mock-сервера в тестах Start() выполняется на фоновом потоке, поэтому Stop() из TestCleanup мог увидеть _server == null до присваивания и оставить живого слушателя. Останов теперь фиксируется под локом: старт, завершившийся после него, гасит собственный Server вместо того, чтобы его сохранить. --- CHANGES.md | 12 + .../Client/TestUChangeServerFailure.cs | 182 +++++++++++++ .../Client/TestUOnConnectedHandlerFailure.cs | 251 ++++++++++++++++++ Tests/Xrpl.Tests/CreateMockRippled.cs | 51 ++++ Xrpl/Client/WebSocketClient.cs | 7 +- Xrpl/Client/connection.cs | 193 +++++++++++++- Xrpl/Xrpl.csproj | 2 +- 7 files changed, 687 insertions(+), 11 deletions(-) create mode 100644 Tests/Xrpl.Tests/Client/TestUChangeServerFailure.cs create mode 100644 Tests/Xrpl.Tests/Client/TestUOnConnectedHandlerFailure.cs diff --git a/CHANGES.md b/CHANGES.md index 19dab341..b9a410e8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,17 @@ # Changes +## 10.10.1.0 08/02/2026 +* **An exception from an `OnConnected` handler no longer kills the client forever** — `Connection.OnceOpen` caught anything thrown by a consumer `OnConnected` handler and called `Disconnect()`, i.e. the *user* disconnect path: it set `_permanentlyDisconnected = true` and called `ClearReconnectState()`. After that the client was dead — the reconnect loop was never restarted, no new socket was ever opened, `OnConnected` never fired again, and every later request threw `NotConnectedException("Client has been disconnected. Call Connect() to reconnect.")`. Nothing was logged and nothing was raised, so from the outside the client just went quiet: + * **The trigger is the most ordinary event there is — a node restart.** `OnConnected` is the natural place to restore subscriptions, because the SDK does not restore them after a reconnect. A restarting node accepts TCP seconds before it starts answering requests, so the first `subscribe` after the reconnect runs into `RequestTimeout` (40 s) and throws. A consumer that lets the exception out — the reasonable "fail loudly, let the SDK reconnect" reaction — got the opposite: a silent, permanent death. Observed in production on a fleet of bots, each wedged for four hours after a node upgrade, one of them dying 69 seconds before the node came back + * A failing handler is now treated as what it is — a **connection** failure, not a user disconnect. The socket is torn down and the regular reconnect loop takes over with its usual exponential backoff, exactly as for a transport failure. The permanent-disconnect flag is never set on this path + * **A permanently broken handler cannot spin forever.** `OnceOpen` clears the reconnect state before invoking the handler, so the loop's own attempt counter resets on every successful TCP connect and could never converge. Consecutive handler failures are therefore counted separately (`_connectHandlerFailures`, reset on a successful handler run, on `Connect()` and on `ChangeServer()`); once they reach `MaxReconnectAttempts` with `StopAfterMaxAttempts` set, the client gives up deliberately — an immediate, actionable `NotConnectedException` instead of a silent five-minute wait — and `Connect()` clears the counter so recovery stays possible. With `StopAfterMaxAttempts = false` it keeps retrying, which is what that option asks for + * **The cause is now observable.** The exception is surfaced through `OnError` with `errorMessage = "connectHandlerError"` (the same shape already used for stream-handler failures) and through `OnConnectionStatus` — previously the reason the client died was reported nowhere at all + * `TestUOnConnectedHandlerFailure` pins all four properties against the mock rippled: a transient failure recovers and the client is usable again, the failure is reported through `OnError`, and a permanently failing handler stops instead of looping +* **`ChangeServer` to a server that is not up leaves the client reconnecting instead of dead** — a second wedge of the same family, found while exercising the fix above through the Blazor demo (switch the network selector to a node that is down). `ChangeServer` set the *global* `_isIntentionalDisconnect` flag to filter late callbacks from the socket it was retiring, and that flag was only ever reset in `OnceOpen`. If the new server never came up, `OnceOpen` never ran: `OnConnectionFailed` then read the failure of the **new** connection as a user disconnect, reported `"Connection closed permanently."`, started no reconnect loop, and every later call — including `ChangeServer` itself — failed with the misleading `"No connection attempt in progress. Call Connect() first."` Starting the server afterwards changed nothing; the client was dead. Late callbacks are now filtered purely by the per-socket tracking that was already in place (`_userInitiatedSockets` plus the socket's own flag), exactly as the ping-timeout/network-drop path has always done — its code even carries a comment warning against setting the global flag for this reason. The flag is additionally cleared on entry, so a `ChangeServer` after a user `Disconnect()` is not suppressed by the leftover either. `TestUChangeServerFailure` pins both cases: the client reaches the new server once it appears, with and without a preceding `Disconnect()` +* **The reconnect loop no longer writes to a reconnect session it no longer owns** — `StopReconnectLoop()` cancels the loop's token without awaiting the loop, so a retired loop could still reach its body or its tail after a replacement had been installed and clear the *live* loop's `_reconnectMode`, reset its `_reconnectAttempts` or dispose its `_reconnectCts`. Pre-existing (`RetireCurrentSessionAndReconnectAsync` has always retired loops this way), but the handler-failure path above makes it far more reachable, so `ReconnectLoopAsync` now takes the `CancellationTokenSource` it owns and touches shared state only while that source is still the active one +* **`WaitForConnectionAsync` now rechecks the permanent-disconnect flag on every iteration**, not only once on entry. A caller already blocked there when the client is disconnected — by `Disconnect()` from another thread, or by the give-up path above — used to sit out the whole `ConnectionAcquisitionTimeout` (default five minutes) and then receive a generic `TimeoutException`. It now returns the actual reason immediately as a `NotConnectedException` +* **`WebSocketClient.SendMessageAsync` no longer swallows send failures silently** — it is `async void` and is invoked without `await` from `Connection.WebsocketSendAsync`, so a failed send could be reported to nobody: the pending request simply sat there until its 40-second `RequestTimeout` expired. The socket's error callback (previously dead code — nothing ever invoked or wired it) now carries the exception to `Connection.OnError` with `errorMessage = "socketSendError"`. Report-only: a failed send does not by itself mean the connection is gone, so this path never triggers a reconnect and the request is still bounded by `RequestTimeout` — but the cause is no longer invisible during diagnosis + ## 10.10.0.0 07/29/2026 * **`ConnectionOptions.authorization` did nothing — now it does** — the option was public on `XrplClient.ClientOptions` since the xrpl.js port, but `Connection.CreateWebSocket` was a block of commented-out JS pseudocode ending in `WebSocketClient.Create(url); // todo add options`, and `WebSocketClient` had no parameter to receive them. Nothing the caller set on `authorization`, `headers`, `proxy`, `trustedCertificates`, `key`, `passphrase` or `certificate` ever reached the socket: * `authorization` now produces `Authorization: Basic base64(value)` on the WebSocket upgrade handshake, matching xrpl.js `createWebSocket` — the value is the raw `user:password` pair, the SDK does the base64 diff --git a/Tests/Xrpl.Tests/Client/TestUChangeServerFailure.cs b/Tests/Xrpl.Tests/Client/TestUChangeServerFailure.cs new file mode 100644 index 00000000..1ab724b6 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestUChangeServerFailure.cs @@ -0,0 +1,182 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using Xrpl.Client; + +namespace Xrpl.Tests +{ + /// + /// Regression tests for ChangeServer pointed at a server that is not up yet. + /// + /// ChangeServer used to set the global _isIntentionalDisconnect flag, which was only ever + /// reset in OnceOpen. When the new server never came up, the flag stayed set, the failure of the + /// new connection was read as a user disconnect ("Connection closed permanently."), no reconnect loop + /// was started, and every later call failed with "No connection attempt in progress. Call Connect() + /// first." - the client was dead even after the server came up. + /// + /// + [TestClass] + public class TestUChangeServerFailure + { + private CreateMockRippled _mockedRippled; + private CreateMockRippled _secondRippled; + private XrplClient _client; + private int _port; + + private static Dictionary ServerInfoResponse() => new Dictionary + { + { "type", "response" }, + { "status", "success" }, + { "result", new Dictionary + { + { "info", new Dictionary + { + { "build_version", "test-mock" }, + { "complete_ledgers", "1-1" }, + { "server_state", "full" }, + } + }, + } + }, + }; + + private static CreateMockRippled StartMock(int port) + { + CreateMockRippled mock = new CreateMockRippled(port) { suppressOutput = true }; + mock.AddResponse("server_info", ServerInfoResponse()); + + Thread listenerThread = new Thread(() => mock.Start()) { IsBackground = true }; + listenerThread.Start(); + return mock; + } + + [TestInitialize] + public void MyTestInitialize() + { + _port = TestUtils.GetFreePort(); + _mockedRippled = StartMock(_port); + } + + [TestCleanup] + public async Task MyTestCleanup() + { + if (_client != null) + { + await _client.Disconnect(); + _client = null; + } + + _mockedRippled?.Stop(); + _secondRippled?.Stop(); + } + + /// + /// Switching to a server that is not listening yet must leave the client reconnecting, so it comes + /// up on its own once that server appears - not stranded in a permanent disconnect. + /// + [TestMethod] + public async Task TestChangeServerToUnreachableServerRecoversWhenItComesUp() + { + _client = new XrplClient($"ws://127.0.0.1:{_port}", new XrplClient.ClientOptions + { + RequestPolicy = RequestFailurePolicy.ImmediateFail, + ReconnectBaseDelay = TimeSpan.FromMilliseconds(200), + ReconnectMaxDelay = TimeSpan.FromSeconds(1), + MaxReconnectAttempts = 50, + StopAfterMaxAttempts = false, + // Short on purpose: ChangeServer gives up waiting quickly, but the reconnect loop it left + // behind is what this test is about. + ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(3), + ConnectionAttemptTimeout = TimeSpan.FromSeconds(3), + UseCustomPing = false, + }); + + await _client.Connect(); + Assert.IsTrue(_client.connection.IsConnected(), "Precondition: client must be connected to the first server."); + + int secondPort = TestUtils.GetFreePort(); // nothing is listening there yet + + try + { + await _client.connection.ChangeServer($"ws://127.0.0.1:{secondPort}"); + } + catch (Exception) + { + // Expected - the target is not up yet. What matters is the state it leaves behind. + } + + Assert.AreNotEqual( + XrpConnectionState.Disconnected, + _client.connection.CurrentConnectionState, + "A ChangeServer target that is down is a connection failure, not a permanent disconnect."); + + // The server appears afterwards - exactly the "start the node later" case. + _secondRippled = StartMock(secondPort); + + DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); + while (!_client.connection.IsConnected() && DateTime.UtcNow < deadline) + { + await Task.Delay(TimeSpan.FromMilliseconds(200)); + } + + Assert.IsTrue( + _client.connection.IsConnected(), + $"Client never reached the new server after it came up (state: {_client.connection.CurrentConnectionState})."); + Assert.AreEqual($"ws://127.0.0.1:{secondPort}", _client.connection.GetUrl()); + + Dictionary response = + await _client.Request(new Dictionary { { "command", "server_info" } }); + Assert.IsNotNull(response, "Client must be usable on the new server."); + } + + /// + /// The same, after an explicit user Disconnect(): the global intentional-disconnect flag left + /// behind by it must not suppress reconnection for the server ChangeServer switches to. + /// + [TestMethod] + public async Task TestChangeServerAfterUserDisconnectStillReconnects() + { + _client = new XrplClient($"ws://127.0.0.1:{_port}", new XrplClient.ClientOptions + { + RequestPolicy = RequestFailurePolicy.ImmediateFail, + ReconnectBaseDelay = TimeSpan.FromMilliseconds(200), + ReconnectMaxDelay = TimeSpan.FromSeconds(1), + MaxReconnectAttempts = 50, + StopAfterMaxAttempts = false, + ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(3), + ConnectionAttemptTimeout = TimeSpan.FromSeconds(3), + UseCustomPing = false, + }); + + await _client.Connect(); + await _client.Disconnect(); + + int secondPort = TestUtils.GetFreePort(); + + try + { + await _client.connection.ChangeServer($"ws://127.0.0.1:{secondPort}"); + } + catch (Exception) + { + // Expected - the target is not up yet. + } + + _secondRippled = StartMock(secondPort); + + DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); + while (!_client.connection.IsConnected() && DateTime.UtcNow < deadline) + { + await Task.Delay(TimeSpan.FromMilliseconds(200)); + } + + Assert.IsTrue( + _client.connection.IsConnected(), + $"Client never reached the new server after a user disconnect (state: {_client.connection.CurrentConnectionState})."); + } + } +} diff --git a/Tests/Xrpl.Tests/Client/TestUOnConnectedHandlerFailure.cs b/Tests/Xrpl.Tests/Client/TestUOnConnectedHandlerFailure.cs new file mode 100644 index 00000000..2869b432 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestUOnConnectedHandlerFailure.cs @@ -0,0 +1,251 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using Xrpl.Client; +using Xrpl.Client.Exceptions; + +namespace Xrpl.Tests +{ + /// + /// Regression tests for the "silent wedge": an exception thrown by a consumer OnConnected + /// handler used to trigger the user-disconnect path (_permanentlyDisconnected = true), + /// which killed the client forever instead of reconnecting. + /// + [TestClass] + public class TestUOnConnectedHandlerFailure + { + private CreateMockRippled _mockedRippled; + private XrplClient _client; + private int _port; + + [TestInitialize] + public void MyTestInitialize() + { + _port = TestUtils.GetFreePort(); + _mockedRippled = new CreateMockRippled(_port) { suppressOutput = true }; + _mockedRippled.AddResponse("server_info", new Dictionary + { + { "type", "response" }, + { "status", "success" }, + { "result", new Dictionary + { + { "info", new Dictionary + { + { "build_version", "test-mock" }, + { "complete_ledgers", "1-1" }, + { "server_state", "full" }, + } + }, + } + }, + }); + + Thread tcpListenerThread = new Thread(() => _mockedRippled.Start()) { IsBackground = true }; + tcpListenerThread.Start(); + } + + [TestCleanup] + public async Task MyTestCleanup() + { + if (_client != null) + { + await _client.Disconnect(); + _client = null; + } + + _mockedRippled?.Stop(); + } + + private XrplClient CreateClient(int maxReconnectAttempts, bool stopAfterMaxAttempts) => + new XrplClient($"ws://127.0.0.1:{_port}", new XrplClient.ClientOptions + { + RequestPolicy = RequestFailurePolicy.ImmediateFail, + ReconnectBaseDelay = TimeSpan.FromMilliseconds(100), + ReconnectMaxDelay = TimeSpan.FromSeconds(1), + MaxReconnectAttempts = maxReconnectAttempts, + StopAfterMaxAttempts = stopAfterMaxAttempts, + ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(20), + ConnectionAttemptTimeout = TimeSpan.FromSeconds(10), + UseCustomPing = false, + }); + + /// + /// A transient failure inside OnConnected (e.g. a subscribe that timed out because the + /// node accepts TCP before it serves requests) must not strand the client: the socket is torn + /// down and the regular reconnect loop must bring it back. + /// + [TestMethod] + public async Task TestTransientOnConnectedFailureRecovers() + { + int invocations = 0; + TaskCompletionSource reconnected = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + _client = CreateClient(maxReconnectAttempts: 10, stopAfterMaxAttempts: false); + _client.connection.OnConnected += () => + { + if (Interlocked.Increment(ref invocations) == 1) + { + throw new InvalidOperationException("subscribe failed after connect"); + } + + reconnected.TrySetResult(true); + return Task.CompletedTask; + }; + + Exception connectError = null; + try + { + await _client.Connect(); + } + catch (Exception error) + { + connectError = error; + } + + Task completed = await Task.WhenAny(reconnected.Task, Task.Delay(TimeSpan.FromSeconds(30))); + + Assert.AreSame( + reconnected.Task, + completed, + $"Client never reconnected after OnConnected threw (invocations: {Volatile.Read(ref invocations)}, connect error: {connectError?.Message ?? "none"})"); + Assert.IsTrue(_client.connection.IsConnected(), "Client must be connected again after recovery."); + } + + /// + /// After recovery the client must still be usable — the permanent-disconnect flag must not be set. + /// + [TestMethod] + public async Task TestClientIsUsableAfterOnConnectedFailure() + { + int invocations = 0; + TaskCompletionSource reconnected = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + _client = CreateClient(maxReconnectAttempts: 10, stopAfterMaxAttempts: false); + _client.connection.OnConnected += () => + { + if (Interlocked.Increment(ref invocations) == 1) + { + throw new InvalidOperationException("subscribe failed after connect"); + } + + reconnected.TrySetResult(true); + return Task.CompletedTask; + }; + + try + { + await _client.Connect(); + } + catch (Exception) + { + // Recovery is asserted below - the initial Connect() may observe the failed attempt. + } + + Task completed = await Task.WhenAny(reconnected.Task, Task.Delay(TimeSpan.FromSeconds(30))); + Assert.AreSame(reconnected.Task, completed, "Client never reconnected after OnConnected threw."); + + Dictionary request = new Dictionary + { + { "command", "server_info" }, + }; + + Dictionary response = await _client.Request(request); + Assert.IsNotNull(response, "Request after recovery must succeed."); + } + + /// + /// A permanently broken handler must not spin forever: with StopAfterMaxAttempts the client + /// gives up after MaxReconnectAttempts consecutive handler failures. + /// + [TestMethod] + public async Task TestPermanentlyFailingOnConnectedHandlerStops() + { + const int maxAttempts = 3; + int invocations = 0; + + _client = CreateClient(maxReconnectAttempts: maxAttempts, stopAfterMaxAttempts: true); + _client.connection.OnConnected += () => + { + Interlocked.Increment(ref invocations); + throw new InvalidOperationException("handler is permanently broken"); + }; + + Exception connectError = null; + try + { + await _client.Connect(); + } + catch (Exception error) + { + connectError = error; + } + + Assert.IsInstanceOfType( + connectError, + $"Giving up must unblock the waiting caller with NotConnectedException, got: {connectError?.GetType().Name ?? "no exception"}."); + + await Task.Delay(TimeSpan.FromSeconds(10)); + int settled = Volatile.Read(ref invocations); + await Task.Delay(TimeSpan.FromSeconds(5)); + + Assert.AreEqual( + settled, + Volatile.Read(ref invocations), + "Client kept retrying a permanently failing OnConnected handler instead of giving up."); + Assert.IsTrue( + settled <= maxAttempts + 1, + $"Handler was retried {settled} times, expected at most {maxAttempts + 1}."); + Assert.IsFalse(_client.connection.IsConnected(), "Client must not report a live connection."); + } + + /// + /// The reason the connection was torn down must be observable through OnError. + /// + [TestMethod] + public async Task TestOnConnectedFailureIsReportedThroughOnError() + { + int invocations = 0; + TaskCompletionSource reported = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + _client = CreateClient(maxReconnectAttempts: 10, stopAfterMaxAttempts: false); + _client.connection.OnError += (error, errorMessage, message, data) => + { + if (errorMessage == "connectHandlerError") + { + reported.TrySetResult(message); + } + + return Task.CompletedTask; + }; + _client.connection.OnConnected += () => + { + if (Interlocked.Increment(ref invocations) == 1) + { + throw new InvalidOperationException("subscribe failed after connect"); + } + + return Task.CompletedTask; + }; + + try + { + await _client.Connect(); + } + catch (Exception) + { + // The failure itself is asserted through OnError below. + } + + Task completed = await Task.WhenAny(reported.Task, Task.Delay(TimeSpan.FromSeconds(30))); + Assert.AreSame(completed, reported.Task, "OnConnected failure was never reported through OnError."); + StringAssert.Contains(reported.Task.Result, "subscribe failed after connect"); + } + } +} diff --git a/Tests/Xrpl.Tests/CreateMockRippled.cs b/Tests/Xrpl.Tests/CreateMockRippled.cs index 37cd57e1..fddc44a4 100644 --- a/Tests/Xrpl.Tests/CreateMockRippled.cs +++ b/Tests/Xrpl.Tests/CreateMockRippled.cs @@ -68,12 +68,51 @@ public class CreateMockRippled private Dictionary _responses = new Dictionary(); public bool suppressOutput = false; private Thread tcpListenerThread; + private readonly object _serverLock = new object(); + private Server _server; + private bool _stopped; public CreateMockRippled(int port) { this._port = port; } + /// + /// Stops the listen socket. Without this the server keeps accepting for the lifetime of the test + /// process, so every test that starts a mock leaks a listener. + /// Start() runs on a background thread, so shutdown is recorded here: a startup that finishes + /// afterwards stops its listener instead of leaving it behind. + /// + public void Stop() + { + Server server; + lock (_serverLock) + { + _stopped = true; + server = _server; + _server = null; + } + + StopServer(server); + } + + private static void StopServer(Server server) + { + if (server == null) + { + return; + } + + try + { + server.Stop(); + } + catch (Exception ex) + { + Debug.WriteLine($"MockRippled stop error: {ex.Message}"); + } + } + string CreateResponse(Dictionary request, Dictionary response) { var cloneResp = new Dictionary(response); @@ -218,6 +257,18 @@ public void Start() Server server = new Server(new IPEndPoint(IPAddress.Parse("127.0.0.1"), this._port)); + lock (_serverLock) + { + if (_stopped) + { + // Stop() already ran - do not leave this listener accepting behind the test's back. + StopServer(server); + return; + } + + _server = server; + } + // Bind the event for when a client connected server.OnClientConnected += (object sender, OnClientConnectedHandler e) => { diff --git a/Xrpl/Client/WebSocketClient.cs b/Xrpl/Client/WebSocketClient.cs index 97cf200a..dd8636e8 100644 --- a/Xrpl/Client/WebSocketClient.cs +++ b/Xrpl/Client/WebSocketClient.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Net.WebSockets; @@ -307,7 +308,11 @@ private async void SendMessageAsync(byte[] message) } catch (Exception e) { - //_onError?.Invoke(e, this); + // The send is fire-and-forget (async void), so nothing can observe this exception: + // the pending request just sits there until its RequestTimeout expires. Surface it + // through the error callback - report-only, the connection itself is left alone. + Debug.WriteLine($"{DateTime.Now}WebSocket send failed: {e.GetType().Name}: {e.Message}"); + CallOnError(e); return; } } diff --git a/Xrpl/Client/connection.cs b/Xrpl/Client/connection.cs index d443c3dd..8860add2 100644 --- a/Xrpl/Client/connection.cs +++ b/Xrpl/Client/connection.cs @@ -280,6 +280,11 @@ private static WebSocketClient CreateWebSocket(string url, ConnectionOptions con private int _reconnectAttempts = 0; + // Number of consecutive times the consumer OnConnected handler threw. + // Not part of the reconnect state: OnceOpen clears the reconnect state before invoking the handler, + // so this counter is the only thing that can bound an endlessly failing handler. + private int _connectHandlerFailures = 0; + private static readonly Random _random = new(); private CancellationTokenSource _reconnectCts; @@ -456,10 +461,16 @@ public async Task ChangeServer( ws = null; } - // 7. Mark socket for intentional disconnect + // 7. Mark old socket for intentional disconnect (per-socket tracking only) + // CRITICAL: Do NOT set global _isIntentionalDisconnect = true here - same rule as the ping/network + // recovery path. The global flag was only reset in OnceOpen, so if the NEW server never came up it + // stayed set forever: OnConnectionFailed then read the failure of the new socket as a user disconnect, + // reported "Connection closed permanently." and started no reconnect loop, leaving the client dead + // with the misleading "No connection attempt in progress. Call Connect() first." + // Per-socket tracking (_userInitiatedSockets + the socket's own flag, set in RetireOldSessionAsync) + // already filters late callbacks from the old socket, and keeps global state clean for the new one. if (oldSocket != null) { - _isIntentionalDisconnect = true; Interlocked.Exchange(ref _userInitiatedSocket, oldSocket); MarkSocketAsUserInitiated(oldSocket); @@ -476,11 +487,15 @@ public async Task ChangeServer( ValidateConfig(); _reconnectAttempts = 0; + Interlocked.Exchange(ref _connectHandlerFailures, value: 0); // 8. Reset permanentlyDisconnected for new connection _permanentlyDisconnected = false; - // _isIntentionalDisconnect stays true - reset in OnceOpen + // Clear the global intentional-disconnect flag explicitly: it may still be set from an earlier + // user Disconnect() (it is only ever reset in OnceOpen), and leaving it set would make a failure + // of the NEW connection look intentional and suppress reconnection. + _isIntentionalDisconnect = false; // 9. Immediately connect to new server (new session created in Connect) await Connect(cancellationToken); @@ -659,6 +674,15 @@ public async Task WaitForConnectionAsync(TimeSpan? timeout = null, CancellationT while (!IsConnected()) { + // Re-checked on every iteration, not only on entry: the client can be disconnected while a + // caller is already waiting here (user Disconnect(), or the client giving up on a permanently + // failing OnConnected handler). Without this the caller would sit out the whole acquisition + // timeout and get a generic TimeoutException instead of the actual reason. + if (_permanentlyDisconnected) + { + throw new NotConnectedException("Client has been disconnected. Call Connect() to reconnect."); + } + if (config.StopAfterMaxAttempts && _reconnectAttempts >= config.MaxReconnectAttempts && _reconnectCts == null) @@ -718,6 +742,7 @@ public async Task Connect(CancellationToken cancellationToken) } StopReconnectLoop(); + Interlocked.Exchange(ref _connectHandlerFailures, value: 0); SetConnectionState(XrpConnectionState.Connecting, message: $"Connecting to {url}..."); await ConnectInternalAsync(); await WaitForConnectionAsync(config.ConnectionAcquisitionTimeout, cancellationToken); @@ -838,6 +863,29 @@ await OnConnectionFailed( } }); + ws.OnError(async (e, errorSocket) => + { + try + { + // Report-only: a failed send does not by itself mean the connection is gone, so this + // path never triggers a reconnect. Without it a fire-and-forget send failure would be + // invisible and the request would simply sit until its RequestTimeout expires. + var errorHandler = OnError; + if (errorHandler is not null) + { + await errorHandler.Invoke( + error: "error", + errorMessage: "socketSendError", + e.Message, + data: e); + } + } + catch (Exception ex) + { + Debug.WriteLine($"{DateTime.Now}OnError callback error: {ex.Message}"); + } + }); + ws.OnMessageReceived(async (m, ws) => { try @@ -1623,15 +1671,16 @@ private async Task OnceOpen(WebSocketClient connectedSocket, long sessionId) await OnConnected?.Invoke(); } + Interlocked.Exchange(ref _connectHandlerFailures, value: 0); SetConnectionState(XrpConnectionState.Connected, message: $"Connected {url}"); } catch (Exception error) { connectionManager.RejectAllAwaiting(error); - await Disconnect(); + await OnConnectHandlerFailedAsync(connectedSocket, error); return; // Don't start ping timer if connection failed } - + // Start ping timer AFTER connection is fully established and all callbacks completed // This is outside try/catch to ensure it always runs on successful connection StartPingTimer(); @@ -1640,6 +1689,109 @@ private async Task OnceOpen(WebSocketClient connectedSocket, long sessionId) StartMessageProcessor(); } + /// + /// Handles an exception thrown by a consumer handler. + /// + /// A failing handler is a CONNECTION failure, not a user disconnect. Calling here + /// would set the permanent-disconnect flag and clear the reconnect state, stranding the client forever: + /// no reconnect loop is restarted, no new socket is ever opened and every later request fails with + /// . This is a very reachable scenario - restoring subscriptions in + /// fails whenever the node accepts TCP before it starts serving requests. + /// + /// + /// Instead the socket is torn down as a transport failure so the regular reconnect loop (with exponential + /// backoff) brings the client back. A handler that keeps failing is bounded by + /// when + /// is set, so a broken consumer cannot spin forever. + /// + /// + /// The socket whose handler threw. + /// The exception thrown by the handler. + private async Task OnConnectHandlerFailedAsync(WebSocketClient failedSocket, Exception error) + { + int failures = Interlocked.Increment(ref _connectHandlerFailures); + + Debug.WriteLine($"{DateTime.Now}OnConnected handler failed ({failures}): {error.Message}"); + + var errorHandler = OnError; + if (errorHandler is not null) + { + try + { + await errorHandler + .Invoke(error: "error", errorMessage: "connectHandlerError", error.Message, data: error) + .ConfigureAwait(false); + } + catch (Exception notifyError) + { + Debug.WriteLine($"{DateTime.Now}OnError handler threw while reporting OnConnected failure: {notifyError.Message}"); + } + } + + bool giveUp = config.StopAfterMaxAttempts && failures >= config.MaxReconnectAttempts; + if (giveUp) + { + // Terminal state on purpose: the handler is broken, not the connection. Disconnect() gives the + // consumer an immediate, actionable NotConnectedException instead of a silent 5-minute wait, + // and Connect() resets the counter so recovery stays possible. + // The detailed reason has to be notified BEFORE Disconnect(): Disconnect() moves the state to + // Disconnected itself, and SetConnectionState only notifies on a state change, so a call after it + // would be swallowed and the consumer would see "Disconnected by user request." instead. + SetConnectionState( + XrpConnectionState.Disconnected, + message: + $"OnConnected handler failed {failures} time(s) in a row: {error.Message}. Giving up after {config.MaxReconnectAttempts} attempts. Call Connect() to retry.", + ConnectionCloseSeverity.Error); + + await Disconnect(); + return; + } + + SetConnectionState( + XrpConnectionState.RestoringConnection, + message: $"OnConnected handler failed: {error.Message}. Reconnecting...", + ConnectionCloseSeverity.Warning, + reconnect: BuildReconnectInfo(failures)); + + StopPingTimerSync(); + requestManager.RejectAllWithCancellation(); + await WaitForPingToFinishAsync(); + + // Always tear down the socket the handler actually ran for. WebSocketClient.Connect invokes its + // OnConnect callback without awaiting it, so the connect lock can be released while this method is + // still running: by now `ws` may already point at a newer socket that must not be touched. + bool wasCurrentSocket; + lock (_disconnectLock) + { + wasCurrentSocket = ReferenceEquals(ws, failedSocket); + if (wasCurrentSocket) + { + ws = null; + } + } + + // The socket is deliberately NOT marked as user-initiated: OnceClose must treat this as a real + // close so the standard reconnect path runs instead of the "closed permanently" branch. + failedSocket.Cancel(); + failedSocket.Disconnect(); + + if (!wasCurrentSocket) + { + // A newer connection already replaced this socket - it owns the reconnect state now. + return; + } + + // Take ownership of the reconnect state instead of asking "is a loop already running?". + // This method can run inside the reconnect loop's own attempt: that loop breaks as soon as the + // socket reports Open, which happens before the handler has even finished failing. Both this check + // and the one in OnceClose would then race with the loop's exit, and losing the race leaves nobody + // reconnecting - the very wedge this path exists to prevent. Cancel whatever is there, start fresh; + // the later OnceClose sees a live loop and correctly stands down. + StopReconnectLoop(); + _reconnectLoop = null; + StartReconnectLoop(); + } + private async Task OnceClose(int? code, string? description, WebSocketClient closingSocket, long sessionId) { var (severity, userMessage) = DescribeClose(code, description); @@ -1837,11 +1989,16 @@ private void StartReconnectLoop() // Don't reset _reconnectAttempts - this is continuation of existing reconnect sequence // Note: _reconnectLoop was already cleared by RetireCurrentSessionAndReconnectAsync - _reconnectLoop = ReconnectLoopAsync(_reconnectCts.Token); + _reconnectLoop = ReconnectLoopAsync(_reconnectCts); } - private async Task ReconnectLoopAsync(CancellationToken ct) + private async Task ReconnectLoopAsync(CancellationTokenSource ownCts) { + // The CTS this loop owns. StopReconnectLoop cancels without awaiting the loop, so a retired loop + // can still be running - or reach its tail - after a replacement has been installed. Everything + // this loop writes to shared reconnect state is therefore guarded by an ownership check. + CancellationToken ct = ownCts.Token; + // Don't reset _reconnectAttempts here - it may be pre-set to 1 by fast reconnect path // StartReconnectLoop() sets it to 0 when creating a new CTS @@ -1855,6 +2012,12 @@ private async Task ReconnectLoopAsync(CancellationToken ct) while (!ct.IsCancellationRequested) { + if (!ReferenceEquals(_reconnectCts, ownCts)) + { + // Retired: a newer loop owns the reconnect sequence now. + break; + } + _reconnectAttempts++; // Skip delay for first attempt if this is immediate reconnect (ping timeout or network drop) @@ -1938,7 +2101,11 @@ private async Task ReconnectLoopAsync(CancellationToken ct) if (IsConnected()) { - _reconnectAttempts = 0; + if (ReferenceEquals(_reconnectCts, ownCts)) + { + _reconnectAttempts = 0; + } + break; } } @@ -1969,13 +2136,21 @@ private async Task ReconnectLoopAsync(CancellationToken ct) // This ensures late callbacks from ping-timeout socket are still filtered // even if reconnect attempts fail + // A newer loop may already have taken over (this one was retired by StopReconnectLoop, which does + // not await it). Its state belongs to that loop: clearing the mode or disposing the CTS here would + // strand the live reconnect sequence. + if (!ReferenceEquals(_reconnectCts, ownCts)) + { + return; + } + // When loop exits (cancelled, max attempts, or success) and connection is not established, // clear the reconnect mode. If connected, OnceOpen already cleared it. if (!IsConnected()) { _reconnectMode = ReconnectMode.None; } - + if (config.StopAfterMaxAttempts && _reconnectAttempts >= config.MaxReconnectAttempts) { _reconnectCts?.Dispose(); diff --git a/Xrpl/Xrpl.csproj b/Xrpl/Xrpl.csproj index 745c024a..d49f6712 100644 --- a/Xrpl/Xrpl.csproj +++ b/Xrpl/Xrpl.csproj @@ -14,7 +14,7 @@ Apache-2.0 https://github.com/StaticBit-io/XrplCSharp XrplCSharp - 10.10.0.0 + 10.10.1.0 From 1d9fa48a2309ab67c835c90e763c5bc8d795ce3b Mon Sep 17 00:00:00 2001 From: Aleksandr <44946855+Platonenkov@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:04:38 -0300 Subject: [PATCH 03/14] =?UTF-8?q?feat(models):=20=D1=84=D0=BB=D0=B0=D0=B3?= =?UTF-8?q?=D0=B8=20ledger-=D0=BE=D0=B1=D1=8A=D0=B5=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D0=B2,=20=D0=B3=D0=B2=D0=B0=D1=80=D0=B4=20=D0=BF=D0=BE=20Ledge?= =?UTF-8?q?rFormats.h=20=D0=B8=20=D0=BF=D0=BE=D0=BA=D1=80=D1=8B=D1=82?= =?UTF-8?q?=D0=B8=D0=B5=20DynamicMPT=20(10.11.0.0)=20(#71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(models): именованные lsf-флаги MPT и интеграционное покрытие DynamicMPT Модели MPT отставали от rippled по флагам ledger-объектов, а DynamicMPT (XLS-94) не имел ни гварда, ни интеграционных тестов: поля MutableFlags в моделях были, но против ноды никогда не проверялись. * MPTokenIssuanceFlags + MPTCanHoldConfidentialBalance (0x80) — вводит ConfidentialTransfer; остальная поддержка амендмента уже полная (транзакции 85-89, IssuerEncryptionKey/AuditorEncryptionKey, ConfidentialOutstandingAmount), не хватало только имени у флага * MPTokenFlags + lsfMPTAMM (0x4) — пробел старше: флаг есть уже в 3.2.1. AMMCreate ставит его вместе с lsfMPTAuthorized, неявно авторизуя MPT-актив для псевдо-аккаунта AMM (src/libxrpl/tx/transactors/dex/AMMCreate.cpp) * AmendmentGuard + DynamicMPT; id совпал с тем, что generate-amendments.sh уже кладёт в [amendments] nightly-стенда * TestIDynamicMPT — 4 теста, каждый читает результат из ledger-объекта: MutableFlags с Create доезжают в LOMPTokenIssuance; MPTokenIssuanceSet меняет TransferFee и MPTokenMetadata, не переписывая MutableFlags; tmfMPTSetCanLock поднимает lsfMPTCanLock на issuance, созданном без него; мутация без разрешения отбивается tecNO_PERMISSION, а метаданные в ledger остаются прежними Сценарии выверены по транзактору rippled 3.3.0-rc1 (src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp), а не по документации: отсюда tfMPTCanTransfer на создании — preclaim требует lsfMPTCanTransfer уже установленным, включение его той же транзакцией правило не удовлетворяет. Проверено: nightly-стенд (3.3.0-b1, DynamicMPT enabled) — 4/4 passed; CI-стенд (xrpld 3.2.0) — 4 skipped через гвард, exit 0; MPT-регрессия 28/28, юнит-тесты 867/867. Значение 0x80 подтверждено на живом объекте: MPTokenIssuanceSet с MutableFlags=0x40 даёт Flags=128. * feat(models): флаги ledger-объектов по LedgerFormats.h и гвард против дрейфа LedgerFormats.h — единственное место, где протокол объявляет, какие lsf-флаги принадлежат какому ledger-объекту (definitions.json несёт коды полей и типы объектов, но не значения флагов). Против него не стоял ни один тест, поэтому пропущенный флаг не давал симптома: безымянный бит всё равно приходит в модель числом, теряется только возможность проверить его по имени. Так lsfMPTAMM прожил несколько релизов. Пофайловый диф всех enum'ов против тега 3.3.0-rc1 нашёл четыре пробела. * MPTokenIssuanceFlags + MPTCanHoldConfidentialBalance (0x80) — от ConfidentialTransfer; значение подтверждено на живом объекте * MPTokenFlags + lsfMPTAMM (0x4) — пробел старше, флаг есть уже в 3.2.1 * LOLoan.Flags — у объекта Loan вообще не было свойства Flags (нет его и в BaseLedgerEntry), то есть lsfLoanDefault/lsfLoanImpaired/lsfLoanOverpayment не читались типизированной моделью никак: статус дефолта и impairment займа был ненаблюдаем. Добавлено как LoanFlags? * новые SignerListFlags и DirectoryNodeFlags — сами свойства Flags остаются raw uint (смена типа была бы breaking), enum'ы дают именованные константы вместо магических чисел; ложный комментарий у LODirectoryNode.Flags про «протокол не определяет флагов» исправлен TestULedgerFlagsConformance — ledger-аналог TestUTxFormatConformance: вендоренный Fixtures/LedgerFormats.h с пином по sha, парсер, падающий громко на незнакомом LSF_FLAG* и на тощем разборе, диф в обе стороны и требование регистрировать каждый флагованный объект — новый объект роняет сборку, а не пропускается молча. Проверен мутациями: неверное значение, удалённый флаг и незарегистрированный объект дают внятное падение. Интеграционное покрытие DynamicMPT (XLS-94): AmendmentGuard + TestIDynamicMPT, 4 теста, каждый читает результат из ledger-объекта. Сценарии выверены по транзактору rippled, а не по документации. Версия Xrpl поднята до 10.11.0.0 (minor: только добавления в публичном API), base-пакеты не менялись. CHANGES.md дополнен. Проверено: nightly-стенд — TestILoan/TestIDynamicMPT/TestIMPToken 41/41 passed; юнит-тесты 869/869; сборка решения без ошибок. * ci(protocol-watch): следить за LedgerFormats.h TestULedgerFlagsConformance сравнивает модели с ПРИПИНЕННОЙ копией LedgerFormats.h, поэтому сам по себе он не может заметить, что апстрим ушёл вперёд — этот сигнал даёт protocol-watch. Заголовка в его списке не было, и это вторая половина причины, по которой lsfMPTAMM прожил несколько релизов: не только никто не сверял флаги, но и никто не сообщал, что файл изменился. Заодно .ref фикстуры перестаёт врать: там написано «когда protocol-watch сообщит об изменении этого файла — замени копию целиком», а вотчер за ним не следил. Путь отсутствует в baseline, поэтому первый же прогон отметит заголовок как изменившийся один раз и внесёт его в baseline наравне с остальными — union ключей в сравнении это предусматривает. --- .github/workflows/protocol-watch.yml | 9 +- CHANGES.md | 23 ++ Tests/Xrpl.Tests/Fixtures/LedgerFormats.h | 322 ++++++++++++++++++ Tests/Xrpl.Tests/Fixtures/LedgerFormats.h.ref | 22 ++ .../Xrpl.Tests/Integration/AmendmentGuard.cs | 3 + .../transactions/TestIDynamicMPT.cs | 211 ++++++++++++ Tests/Xrpl.Tests/Models/RippledLedgerFlags.cs | 110 ++++++ .../Models/TestULedgerFlagsConformance.cs | 149 ++++++++ Tests/Xrpl.Tests/Xrpl.Tests.csproj | 3 + Xrpl/Models/Ledger/LODirectoryNode.cs | 25 +- Xrpl/Models/Ledger/LOLoan.cs | 28 ++ Xrpl/Models/Ledger/LOMPToken.cs | 5 + Xrpl/Models/Ledger/LOMPTokenIssuance.cs | 6 + Xrpl/Models/Ledger/LOSignerList.cs | 17 + Xrpl/Xrpl.csproj | 4 +- 15 files changed, 930 insertions(+), 7 deletions(-) create mode 100644 Tests/Xrpl.Tests/Fixtures/LedgerFormats.h create mode 100644 Tests/Xrpl.Tests/Fixtures/LedgerFormats.h.ref create mode 100644 Tests/Xrpl.Tests/Integration/transactions/TestIDynamicMPT.cs create mode 100644 Tests/Xrpl.Tests/Models/RippledLedgerFlags.cs create mode 100644 Tests/Xrpl.Tests/Models/TestULedgerFlagsConformance.cs diff --git a/.github/workflows/protocol-watch.yml b/.github/workflows/protocol-watch.yml index 2d46582c..f0295cde 100644 --- a/.github/workflows/protocol-watch.yml +++ b/.github/workflows/protocol-watch.yml @@ -7,9 +7,11 @@ name: Protocol Watch # # Coverage is an explicit list of full repo-relative paths (WATCH), so files # from any directory can be tracked: the five protocol .macro definition files -# plus TxFlags.h (transaction flags) and TER.h (result codes), which the SDK -# mirrors as flag enums and the EngineResult enum but which live outside the -# macros. +# plus three headers the SDK mirrors but which live outside the macros — +# TxFlags.h (transaction flags), TER.h (result codes) and LedgerFormats.h +# (lsf ledger-object flags, vendored as a test fixture and diffed by +# TestULedgerFlagsConformance, which compares against a pinned copy and so +# cannot notice upstream moving on its own). # # State lives in the tracking issue body (labeled protocol-watch), not in the # repo and not in actions cache: no commits through branch protection, no @@ -40,6 +42,7 @@ env: include/xrpl/protocol/detail/transactions.macro include/xrpl/protocol/TxFlags.h include/xrpl/protocol/TER.h + include/xrpl/protocol/LedgerFormats.h ISSUE_TITLE: 'Protocol watch: rippled develop' ISSUE_LABEL: protocol-watch diff --git a/CHANGES.md b/CHANGES.md index b9a410e8..4466a442 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,28 @@ # Changes +## 10.11.0.0 08/03/2026 +* **Ledger-object flags the protocol declares but the models never named** — an unnamed bit still arrives in the model as a number, so reading the object kept working and only the consumer's ability to test it by name was lost. That is why these went unnoticed; a field-by-field diff of rippled `LedgerFormats.h` (tag `3.3.0-rc1`) against every flag enum found four gaps: + * `MPTokenIssuanceFlags` + **`MPTCanHoldConfidentialBalance`** (0x80) — introduced by ConfidentialTransfer. The rest of the amendment was already complete (transactions 85–89, `IssuerEncryptionKey`/`AuditorEncryptionKey`, `ConfidentialOutstandingAmount`); only the flag had no name. Value confirmed against a live node: `MPTokenIssuanceSet` with `MutableFlags = tmfMPTSetCanHoldConfidentialBalance` moves the issuance from `Flags = 0` to `Flags = 128` + * `MPTokenFlags` + **`lsfMPTAMM`** (0x4) — a much older gap: the flag is present as far back as 3.2.1. `AMMCreate` sets it together with `lsfMPTAuthorized` to implicitly authorize an MPT asset for the AMM pseudo-account + * **`LOLoan.Flags`** — the Loan ledger object had no `Flags` property at all (and `BaseLedgerEntry` has none either), so `lsfLoanDefault`/`lsfLoanImpaired`/`lsfLoanOverpayment` were unreadable through the typed model: the default and impairment state of a loan could not be observed at all. Added as a typed `LoanFlags?` together with the enum + * new `SignerListFlags` (`lsfOneOwnerCount`) and `DirectoryNodeFlags` (`lsfNFTokenBuyOffers`/`lsfNFTokenSellOffers`) — both objects expose `Flags` as a raw `uint` and **keep doing so** (changing the property type would be breaking); the enums give consumers named constants to test bits against instead of magic numbers. The `LODirectoryNode.Flags` comment claiming "the protocol defines no flags for DirectoryNode objects" was false and is corrected + +* **`TestULedgerFlagsConformance` — the guard that would have caught all of the above** — `LedgerFormats.h` is the only place the protocol states which `lsf` flags belong to which ledger object (`definitions.json` carries field codes and entry types, but no flag values). Nothing checked it, which is how `lsfMPTAMM` survived several releases. The new test is the ledger-side counterpart of `TestUTxFormatConformance`: + * `Tests/Xrpl.Tests/Fixtures/LedgerFormats.h` is vendored byte-identical and pinned by sha in `LedgerFormats.h.ref`, verifiable with a plain `curl … | diff`. Pinned rather than live for the same reason as `transactions.macro`: upstream drift is protocol-watch's job, and a network-backed test would go red on Ripple's release schedule instead of ours + * `RippledLedgerFlags` parses the `LEDGER_OBJECT`/`LSF_FLAG` macro text and fails loudly — an unknown `LSF_FLAG*` variant or a parse yielding fewer than 10 objects / 50 flags throws rather than leaving the test green on an empty table + * the test diffs **both directions** (a flag rippled declares and the enum lacks, a flag the enum has and rippled does not) and requires every flagged object to be registered against a model enum, so a newly added ledger object fails the build instead of being skipped. `tf*` members sharing an enum with ledger flags (`OfferFlags.tfInnerBatchTxn`) and zero-valued members are excluded by rule + * name matching normalizes the `lsf`/`lsmf`/`tmf` prefixes, so `lsfMPTLocked` ≡ `MPTLocked` and rippled's `lsmfMPTCanEnableCanLock` ≡ the SDK's `tmfMPTCanEnableCanLock` (rippled itself aliases `tmfX = lsmfX` in `TxFlags.h`) + * verified by mutation, not just by passing: a wrong value, a removed flag and an unregistered object each make it fail with a readable message + * `protocol-watch` now watches `include/xrpl/protocol/LedgerFormats.h` as well. A pinned fixture cannot notice upstream moving — that signal is the watcher's job, and the header was missing from its list (which is the other half of why `lsfMPTAMM` went unnoticed for so long). The first run after this change reports the header as changed once, then carries it in the baseline like the rest + +* **DynamicMPT (XLS-94) integration coverage** — the `MutableFlags` fields existed on the models but had never been exercised against a node. `AmendmentGuard` gains the `DynamicMPT` id (it matches what `generate-amendments.sh` already writes into the nightly stand's `[amendments]`), and `TestIDynamicMPT` covers the amendment end to end, each test reading the result back from the ledger object rather than trusting `EngineResult`: + * `MutableFlags` set at `MPTokenIssuanceCreate` reach `LOMPTokenIssuance` unchanged + * `MPTokenIssuanceSet` mutates `TransferFee` and `MPTokenMetadata`, and leaves `MutableFlags` alone (rippled `doApply` only writes `sfFlags`/`sfTransferFee`/`sfMPTokenMetadata`) + * `tmfMPTSetCanLock` raises `lsfMPTCanLock` on an issuance created **without** that capability + * a mutation the issuance never permitted is rejected with `tecNO_PERMISSION` and leaves the metadata untouched + * scenarios were derived from the transactor (`src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp` @ `3.3.0-rc1`), not from the docs — hence `tfMPTCanTransfer` at creation in the fee test: `preclaim` requires `lsfMPTCanTransfer` to be **already** set, and enabling it in the same transaction does not satisfy the rule + * amendment-gated, so it skips on the CI stand (rippled 3.2.x has `DynamicMPT` as `Supported::No`) and runs for real on the nightly stand + ## 10.10.1.0 08/02/2026 * **An exception from an `OnConnected` handler no longer kills the client forever** — `Connection.OnceOpen` caught anything thrown by a consumer `OnConnected` handler and called `Disconnect()`, i.e. the *user* disconnect path: it set `_permanentlyDisconnected = true` and called `ClearReconnectState()`. After that the client was dead — the reconnect loop was never restarted, no new socket was ever opened, `OnConnected` never fired again, and every later request threw `NotConnectedException("Client has been disconnected. Call Connect() to reconnect.")`. Nothing was logged and nothing was raised, so from the outside the client just went quiet: * **The trigger is the most ordinary event there is — a node restart.** `OnConnected` is the natural place to restore subscriptions, because the SDK does not restore them after a reconnect. A restarting node accepts TCP seconds before it starts answering requests, so the first `subscribe` after the reconnect runs into `RequestTimeout` (40 s) and throws. A consumer that lets the exception out — the reasonable "fail loudly, let the SDK reconnect" reaction — got the opposite: a silent, permanent death. Observed in production on a fleet of bots, each wedged for four hours after a node upgrade, one of them dying 69 seconds before the node came back diff --git a/Tests/Xrpl.Tests/Fixtures/LedgerFormats.h b/Tests/Xrpl.Tests/Fixtures/LedgerFormats.h new file mode 100644 index 00000000..7c504f6b --- /dev/null +++ b/Tests/Xrpl.Tests/Fixtures/LedgerFormats.h @@ -0,0 +1,322 @@ +#pragma once + +// NOLINTBEGIN(readability-identifier-naming) + +#include +#include + +#include +#include +#include +#include +#include + +namespace xrpl { +/** + * Identifiers for on-ledger objects. + * + * Each ledger object requires a unique type identifier, which is stored within the object itself; + * this makes it possible to iterate the entire ledger and determine each object's type and verify + * that the object you retrieved from a given hash matches the expected type. + * + * @warning Since these values are stored inside objects stored on the ledger they are part of the + * protocol. + * **Changing them should be avoided because without special handling, this will result in a hard + * fork.** + * + * @note Values outside this range may be used internally by the code for various purposes, but + * attempting to use such values to identify on-ledger objects will result in an invariant failure. + * + * @note When retiring types, the specific values should not be removed but should be marked as + * [[deprecated]]. This is to avoid accidental reuse of identifiers. + * + * @todo The C++ language does not enable checking for duplicate values here. + * If it becomes possible then we should do this. + * + * @ingroup protocol + */ +// Protocol-critical, hundreds of usages +// NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) +enum LedgerEntryType : std::uint16_t { + +#pragma push_macro("LEDGER_ENTRY") +#undef LEDGER_ENTRY + +#define LEDGER_ENTRY(tag, value, ...) tag = value, + +#include + +#undef LEDGER_ENTRY +#pragma pop_macro("LEDGER_ENTRY") + + //--------------------------------------------------------------------------- + /** + * A special type, matching any ledger entry type. + * + * The value does not represent a concrete type, but rather is used in contexts where the + * specific type of a ledger object is unimportant, unknown or unavailable. + * + * Objects with this special type cannot be created or stored on the ledger. + * + * @see keylet::unchecked + */ + ltANY = 0, + + /** + * A special type, matching any ledger type except directory nodes. + * + * The value does not represent a concrete type, but rather is used in contexts where the + * ledger object must not be a directory node but its specific type is otherwise unimportant, + * unknown or unavailable. + * + * Objects with this special type cannot be created or stored on the ledger. + * + * @see keylet::child + */ + ltCHILD = 0x1CD2, + + //--------------------------------------------------------------------------- + /** + * A legacy, deprecated type. + * + * @deprecated **This object type is not supported and should not be used.** + * Support for this type of object was never implemented. + * No objects of this type were ever created. + */ + ltNICKNAME [[deprecated("This object type is not supported and should not be used.")]] = 0x006e, + + /** + * A legacy, deprecated type. + * + * @deprecated **This object type is not supported and should not be used.** + * Support for this type of object was never implemented. + * No objects of this type were ever created. + */ + ltCONTRACT [[deprecated("This object type is not supported and should not be used.")]] = 0x0063, + + /** + * A legacy, deprecated type. + * + * @deprecated **This object type is not supported and should not be used.** + * Support for this type of object was never implemented. + * No objects of this type were ever created. + */ + ltGENERATOR_MAP [[deprecated("This object type is not supported and should not be used.")]] = + 0x0067, +}; + +/** + * Ledger object flags. + * + * These flags are specified in ledger objects and modify their behavior. + * + * @warning Ledger object flags form part of the protocol. + * **Changing them should be avoided because without special handling, this will result in a hard + * fork.** + * + * @ingroup protocol + */ +#pragma push_macro("XMACRO") +#pragma push_macro("TO_VALUE") +#pragma push_macro("VALUE_TO_MAP") +#pragma push_macro("NULL_NAME") +#pragma push_macro("TO_MAP") +#pragma push_macro("ALL_LEDGER_FLAGS") + +#undef XMACRO +#undef TO_VALUE +#undef VALUE_TO_MAP +#undef NULL_NAME +#undef TO_MAP + +#undef ALL_LEDGER_FLAGS + +// clang-format off + +#define XMACRO(LEDGER_OBJECT, LSF_FLAG, LSF_FLAG2) \ + LEDGER_OBJECT(AccountRoot, \ + LSF_FLAG(lsfPasswordSpent, 0x00010000) /* True, if password set fee is spent. */ \ + LSF_FLAG(lsfRequireDestTag, 0x00020000) /* True, to require a DestinationTag for payments. */ \ + LSF_FLAG(lsfRequireAuth, 0x00040000) /* True, to require a authorization to hold IOUs. */ \ + LSF_FLAG(lsfDisallowXRP, 0x00080000) /* True, to disallow sending XRP. */ \ + LSF_FLAG(lsfDisableMaster, 0x00100000) /* True, force regular key */ \ + LSF_FLAG(lsfNoFreeze, 0x00200000) /* True, cannot freeze ripple states */ \ + LSF_FLAG(lsfGlobalFreeze, 0x00400000) /* True, all assets frozen */ \ + LSF_FLAG(lsfDefaultRipple, 0x00800000) /* True, incoming trust lines allow rippling by default */ \ + LSF_FLAG(lsfDepositAuth, 0x01000000) /* True, all deposits require authorization */ \ + LSF_FLAG(lsfDisallowIncomingNFTokenOffer, 0x04000000) /* True, reject new incoming NFT offers */ \ + LSF_FLAG(lsfDisallowIncomingCheck, 0x08000000) /* True, reject new checks */ \ + LSF_FLAG(lsfDisallowIncomingPayChan, 0x10000000) /* True, reject new paychans */ \ + LSF_FLAG(lsfDisallowIncomingTrustline, 0x20000000) /* True, reject new trustlines (only if no issued assets) */ \ + LSF_FLAG(lsfAllowTrustLineLocking, 0x40000000) /* True, enable trustline locking */ \ + LSF_FLAG(lsfAllowTrustLineClawback, 0x80000000)) /* True, enable clawback */ \ + \ + LEDGER_OBJECT(Offer, \ + LSF_FLAG(lsfPassive, 0x00010000) \ + LSF_FLAG(lsfSell, 0x00020000) /* True, offer was placed as a sell. */ \ + LSF_FLAG(lsfHybrid, 0x00040000)) /* True, offer is hybrid. */ \ + \ + LEDGER_OBJECT(RippleState, \ + LSF_FLAG(lsfLowReserve, 0x00010000) /* True, if entry counts toward reserve. */ \ + LSF_FLAG(lsfHighReserve, 0x00020000) \ + LSF_FLAG(lsfLowAuth, 0x00040000) \ + LSF_FLAG(lsfHighAuth, 0x00080000) \ + LSF_FLAG(lsfLowNoRipple, 0x00100000) \ + LSF_FLAG(lsfHighNoRipple, 0x00200000) \ + LSF_FLAG(lsfLowFreeze, 0x00400000) /* True, low side has set freeze flag */ \ + LSF_FLAG(lsfHighFreeze, 0x00800000) /* True, high side has set freeze flag */ \ + LSF_FLAG(lsfAMMNode, 0x01000000) /* True, trust line to AMM. */ \ + /* Used by client apps to identify payments via AMM. */ \ + LSF_FLAG(lsfLowDeepFreeze, 0x02000000) /* True, low side has set deep freeze flag */ \ + LSF_FLAG(lsfHighDeepFreeze, 0x04000000)) /* True, high side has set deep freeze flag */ \ + \ + LEDGER_OBJECT(SignerList, \ + LSF_FLAG(lsfOneOwnerCount, 0x00010000)) /* True, uses only one OwnerCount */ \ + \ + LEDGER_OBJECT(DirNode, \ + LSF_FLAG(lsfNFTokenBuyOffers, 0x00000001) \ + LSF_FLAG(lsfNFTokenSellOffers, 0x00000002)) \ + \ + LEDGER_OBJECT(NFTokenOffer, \ + LSF_FLAG(lsfSellNFToken, 0x00000001)) \ + \ + LEDGER_OBJECT(MPTokenIssuance, \ + LSF_FLAG(lsfMPTLocked, 0x00000001) /* Also used in ltMPTOKEN */ \ + LSF_FLAG(lsfMPTCanLock, 0x00000002) \ + LSF_FLAG(lsfMPTRequireAuth, 0x00000004) \ + LSF_FLAG(lsfMPTCanEscrow, 0x00000008) \ + LSF_FLAG(lsfMPTCanTrade, 0x00000010) \ + LSF_FLAG(lsfMPTCanTransfer, 0x00000020) \ + LSF_FLAG(lsfMPTCanClawback, 0x00000040) \ + LSF_FLAG(lsfMPTCanHoldConfidentialBalance, 0x00000080)) \ + \ + LEDGER_OBJECT(MPTokenIssuanceMutable, \ + LSF_FLAG(lsmfMPTCanEnableCanLock, 0x00000002) \ + LSF_FLAG(lsmfMPTCanEnableRequireAuth, 0x00000004) \ + LSF_FLAG(lsmfMPTCanEnableCanEscrow, 0x00000008) \ + LSF_FLAG(lsmfMPTCanEnableCanTrade, 0x00000010) \ + LSF_FLAG(lsmfMPTCanEnableCanTransfer, 0x00000020) \ + LSF_FLAG(lsmfMPTCanEnableCanClawback, 0x00000040) \ + LSF_FLAG(lsmfMPTCannotEnableCanHoldConfidentialBalance, 0x00000080) \ + LSF_FLAG(lsmfMPTCanMutateMetadata, 0x00010000) \ + LSF_FLAG(lsmfMPTCanMutateTransferFee, 0x00020000)) \ + \ + LEDGER_OBJECT(MPToken, \ + LSF_FLAG2(lsfMPTLocked, 0x00000001) \ + LSF_FLAG(lsfMPTAuthorized, 0x00000002) \ + LSF_FLAG(lsfMPTAMM, 0x00000004)) \ + \ + LEDGER_OBJECT(Credential, \ + LSF_FLAG(lsfAccepted, 0x00010000)) \ + \ + LEDGER_OBJECT(Vault, \ + LSF_FLAG(lsfVaultPrivate, 0x00010000)) \ + \ + LEDGER_OBJECT(Loan, \ + LSF_FLAG(lsfLoanDefault, 0x00010000) \ + LSF_FLAG(lsfLoanImpaired, 0x00020000) \ + LSF_FLAG(lsfLoanOverpayment, 0x00040000)) /* True, loan allows overpayments */ \ + \ + LEDGER_OBJECT(Sponsorship, \ + LSF_FLAG(lsfSponsorshipRequireSignForFee, 0x00010000) \ + LSF_FLAG(lsfSponsorshipRequireSignForReserve, 0x00020000)) + +// clang-format on + +// Create all the flag values as an enum. +// +// example: +// enum LedgerSpecificFlags { +// lsfPasswordSpent = 0x00010000, +// lsfRequireDestTag = 0x00020000, +// ... +// }; +#define TO_VALUE(name, value) name = (value), +#define NULL_NAME(name, values) values +#define NULL_OUTPUT(name, value) +// Bitwise flag enum +// NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) +enum LedgerSpecificFlags : std::uint32_t { XMACRO(NULL_NAME, TO_VALUE, NULL_OUTPUT) }; + +// Create getter functions for each set of flags using Meyer's singleton pattern. +// This avoids static initialization order fiasco while still providing efficient access. +// This is used below in `getAllLedgerFlags()` to generate the server_definitions RPC output. +// +// example: +// inline LedgerFlagMap const& getAccountRootFlags() { +// static LedgerFlagMap const flags = { +// {"lsfPasswordSpent", 0x00010000}, +// {"lsfRequireDestTag", 0x00020000}, +// ...}; +// return flags; +// } +using LedgerFlagMap = std::map; +#define VALUE_TO_MAP(name, value) {#name, value}, +#define TO_MAP(name, values) \ + inline LedgerFlagMap const& get##name##Flags() \ + { \ + static LedgerFlagMap const flags = {values}; \ + return flags; \ + } +XMACRO(TO_MAP, VALUE_TO_MAP, VALUE_TO_MAP) + +// Create a getter function for all ledger flag maps using Meyer's singleton pattern. +// This is used to generate the server_definitions RPC output. +// +// example: +// inline std::vector> const& getAllLedgerFlags() { +// static std::vector> const flags = { +// {"AccountRoot", getAccountRootFlags()}, +// ...}; +// return flags; +// } +#define ALL_LEDGER_FLAGS(name, values) {#name, get##name##Flags()}, +inline std::vector> const& +getAllLedgerFlags() +{ + static std::vector> const flags = { + XMACRO(ALL_LEDGER_FLAGS, NULL_OUTPUT, NULL_OUTPUT)}; + return flags; +} + +#undef XMACRO +#undef TO_VALUE +#undef VALUE_TO_MAP +#undef NULL_NAME +#undef NULL_OUTPUT +#undef TO_MAP +#undef ALL_LEDGER_FLAGS + +#pragma pop_macro("XMACRO") +#pragma pop_macro("TO_VALUE") +#pragma pop_macro("VALUE_TO_MAP") +#pragma pop_macro("NULL_NAME") +#pragma pop_macro("TO_MAP") +#pragma pop_macro("ALL_LEDGER_FLAGS") + +//------------------------------------------------------------------------------ + +/** + * Holds the list of known ledger entry formats. + */ +class LedgerFormats : public KnownFormats +{ +private: + /** + * Create the object. + * This will load the object with all the known ledger formats. + */ + LedgerFormats(); + +public: + static LedgerFormats const& + getInstance(); + + // Fields shared by all ledger entry formats: + static std::vector const& + getCommonFields(); +}; + +} // namespace xrpl + +// NOLINTEND(readability-identifier-naming) diff --git a/Tests/Xrpl.Tests/Fixtures/LedgerFormats.h.ref b/Tests/Xrpl.Tests/Fixtures/LedgerFormats.h.ref new file mode 100644 index 00000000..f776654f --- /dev/null +++ b/Tests/Xrpl.Tests/Fixtures/LedgerFormats.h.ref @@ -0,0 +1,22 @@ +https://github.com/XRPLF/rippled/blob/3.3.0-rc1/include/xrpl/protocol/LedgerFormats.h +sha 18e311e1e245bcc1813363bd1771b94931585d80 +date 2026-07-16T13:54:12Z +tag 3.3.0-rc1 + +LedgerFormats.h is vendored byte-identical to the ref above so that it can be +re-verified with a plain diff: + + curl -sSL https://raw.githubusercontent.com/XRPLF/rippled/18e311e1e245bcc1813363bd1771b94931585d80/include/xrpl/protocol/LedgerFormats.h \ + | diff - Tests/Xrpl.Tests/Fixtures/LedgerFormats.h + +This is the only place the protocol states which lsf flags belong to which ledger +object: definitions.json carries field codes and object types but no flag values, +so it cannot answer this question. + +Pinned to a tag rather than to develop on purpose — the same reason +transactions.macro is pinned: tracking upstream drift is protocol-watch's job, and +a network-backed test would go red on Ripple's release schedule instead of ours. + +Do not hand-edit it. When protocol-watch reports a change to this file upstream, +replace it wholesale, update the sha above, and let TestULedgerFlagsConformance +show which model enums have to follow. diff --git a/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs b/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs index 671f7c5f..e8135c9a 100644 --- a/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs +++ b/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs @@ -29,6 +29,9 @@ public static class AmendmentGuard /// Amendment id of ConfidentialTransfer (sha512half of the name). public const string ConfidentialTransfer = "2110E4A19966E2EF517C0A8C56A5F35099D7665B0BB89D7B126B30D50B86AAD5"; + /// Amendment id of DynamicMPT / XLS-94 (sha512half of the name). + public const string DynamicMPT = "58E92F338758479C06084E1B6BA366BAD8F75E5329A7F0EEAFFFDA51E5106B7F"; + /// Amendment id of PriceOracle / XLS-47 (sha512half of the name). public const string PriceOracle = "96FD2F293A519AE1DB6F8BED23E4AD9119342DA7CB6BAFD00953D16C54205D8B"; diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIDynamicMPT.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIDynamicMPT.cs new file mode 100644 index 00000000..0256d7af --- /dev/null +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIDynamicMPT.cs @@ -0,0 +1,211 @@ +using System.Threading.Tasks; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Client; +using Xrpl.Client.Exceptions; +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; +using Xrpl.Models.Transactions; +using Xrpl.Sugar; +using Xrpl.Wallet; + +namespace XrplTests.Xrpl.ClientLib.Integration; + +/// +/// DynamicMPT (XLS-94) end-to-end coverage: an issuance declares at creation +/// which capabilities and fields may change later (MutableFlags), and a +/// later MPTokenIssuanceSet performs the mutation. Both directions are checked +/// against the ledger object, plus the permission rule that makes the feature +/// meaningful — a mutation the issuance never allowed is rejected. +/// +/// Amendment-gated: DynamicMPT is Supported::No on rippled 3.2.x, so these +/// tests skip on the CI stand and run for real on the nightly stand, where +/// generate-amendments.sh puts DynamicMPT into [amendments]. +/// +[TestClass] +[TestCategory("DynamicMPT")] +public class TestIDynamicMPT : TestIMPTokenBase +{ + private static IXrplClient client; + private static bool dynamicMptActive; + + protected override IXrplClient GetClient() => client; + + /// "MPT-METADATA" in hex — the value the issuance is created with. + private const string InitialMetadata = "4D50542D4D45544144415441"; + + /// "MPT-UPDATED" in hex — the value a mutation writes over it. + private const string UpdatedMetadata = "4D50542D55504441544544"; + + [ClassInitialize] + public static async Task ClassInitializeAsync(TestContext testContext) + { + client = await CreateStandaloneClient(); + dynamicMptActive = await AmendmentGuard.IsEnabledAsync(client, AmendmentGuard.DynamicMPT); + } + + [TestInitialize] + public void CheckAmendment() + { + if (!dynamicMptActive) + { + Assert.Inconclusive("DynamicMPT amendment is not enabled on the test node; run the nightly stand (.ci-config/docker-compose.batchv11.yml)."); + } + } + + [ClassCleanup] + public static void ClassCleanup() => client?.Dispose(); + + [TestMethod] + public async Task TestDynamicMPT_MutableFlagsOnCreate_ReachTheLedgerObject() + { + XrplWallet issuer = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletAsync(client, issuer, nodeType); + + MPTokenIssuanceCreateMutableFlags mutable = + MPTokenIssuanceCreateMutableFlags.tmfMPTCanMutateMetadata | + MPTokenIssuanceCreateMutableFlags.tmfMPTCanMutateTransferFee | + MPTokenIssuanceCreateMutableFlags.tmfMPTCanEnableCanLock; + + string issuanceId = await CreateIssuance(issuer, MPTokenIssuanceCreateFlags.tfMPTCanTransfer, mutable, InitialMetadata); + + LOMPTokenIssuance issuance = await ReadIssuance(issuanceId); + + Assert.IsNotNull(issuance.MutableFlags, "MutableFlags should be present on the issuance"); + Assert.AreEqual((uint)mutable, issuance.MutableFlags.Value, "MutableFlags should round-trip unchanged"); + Assert.AreEqual(InitialMetadata, issuance.MPTokenMetadata, "MPTokenMetadata should round-trip unchanged"); + } + + [TestMethod] + public async Task TestDynamicMPT_MutateTransferFeeAndMetadata() + { + XrplWallet issuer = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletAsync(client, issuer, nodeType); + + // TransferFee > 0 requires lsfMPTCanTransfer to be already set on the + // issuance (rippled MPTokenIssuanceSet::preclaim), hence tfMPTCanTransfer here. + string issuanceId = await CreateIssuance( + issuer, + MPTokenIssuanceCreateFlags.tfMPTCanTransfer, + MPTokenIssuanceCreateMutableFlags.tmfMPTCanMutateMetadata | + MPTokenIssuanceCreateMutableFlags.tmfMPTCanMutateTransferFee, + InitialMetadata); + + MPTokenIssuanceSet mutation = new MPTokenIssuanceSet + { + Account = issuer.ClassicAddress, + MPTokenIssuanceID = issuanceId, + TransferFee = 500, + MPTokenMetadata = UpdatedMetadata, + }; + mutation = await client.Autofill(mutation); + TransactionSummary result = await client.SubmitAndWait(mutation, issuer, true); + ValidateResult(result); + + LOMPTokenIssuance issuance = await ReadIssuance(issuanceId); + + Assert.AreEqual((ushort)500, issuance.TransferFee, "TransferFee should be the mutated value"); + Assert.AreEqual(UpdatedMetadata, issuance.MPTokenMetadata, "MPTokenMetadata should be the mutated value"); + + // doApply writes lsf flags, TransferFee and metadata; it never rewrites + // sfMutableFlags, so the permission set survives the mutation + Assert.IsNotNull(issuance.MutableFlags, "MutableFlags should still be present after a mutation"); + Assert.AreEqual( + (uint)(MPTokenIssuanceCreateMutableFlags.tmfMPTCanMutateMetadata | + MPTokenIssuanceCreateMutableFlags.tmfMPTCanMutateTransferFee), + issuance.MutableFlags.Value, + "MutableFlags should be unchanged by a mutation"); + } + + [TestMethod] + public async Task TestDynamicMPT_SetMutableFlag_EnablesCapabilityOnTheIssuance() + { + XrplWallet issuer = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletAsync(client, issuer, nodeType); + + // Created WITHOUT tfMPTCanLock — only with the permission to enable it later + string issuanceId = await CreateIssuance( + issuer, + null, + MPTokenIssuanceCreateMutableFlags.tmfMPTCanEnableCanLock, + null); + + LOMPTokenIssuance before = await ReadIssuance(issuanceId); + Assert.IsTrue( + (before.Flags.GetValueOrDefault() & MPTokenIssuanceFlags.MPTCanLock) == 0, + "MPTCanLock should not be set before the mutation"); + + MPTokenIssuanceSet enable = new MPTokenIssuanceSet + { + Account = issuer.ClassicAddress, + MPTokenIssuanceID = issuanceId, + MutableFlags = MPTokenIssuanceSetMutableFlags.tmfMPTSetCanLock, + }; + enable = await client.Autofill(enable); + TransactionSummary result = await client.SubmitAndWait(enable, issuer, true); + ValidateResult(result); + + LOMPTokenIssuance after = await ReadIssuance(issuanceId); + Assert.IsTrue( + (after.Flags.GetValueOrDefault() & MPTokenIssuanceFlags.MPTCanLock) != 0, + "MPTCanLock should be set after tmfMPTSetCanLock"); + } + + [TestMethod] + public async Task TestDynamicMPT_MutationWithoutPermission_IsRejected() + { + XrplWallet issuer = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletAsync(client, issuer, nodeType); + + // No MutableFlags at all: nothing about this issuance may be changed later + string issuanceId = await CreateIssuance(issuer, null, null, InitialMetadata); + + MPTokenIssuanceSet mutation = new MPTokenIssuanceSet + { + Account = issuer.ClassicAddress, + MPTokenIssuanceID = issuanceId, + MPTokenMetadata = UpdatedMetadata, + }; + mutation = await client.Autofill(mutation); + + await Helper.ThrowsExceptionAsync( + () => client.SubmitAndWait(mutation, issuer, true), + "Final tx result is not success: tecNO_PERMISSION"); + + LOMPTokenIssuance issuance = await ReadIssuance(issuanceId); + Assert.AreEqual(InitialMetadata, issuance.MPTokenMetadata, "MPTokenMetadata should be untouched by the rejected mutation"); + } + + private static async Task CreateIssuance( + XrplWallet issuer, + MPTokenIssuanceCreateFlags? flags, + MPTokenIssuanceCreateMutableFlags? mutableFlags, + string metadata) + { + MPTokenIssuanceCreate create = new MPTokenIssuanceCreate + { + Account = issuer.ClassicAddress, + Flags = flags, + MutableFlags = mutableFlags, + MPTokenMetadata = metadata, + }; + create = await client.Autofill(create); + TransactionSummary created = await client.SubmitAndWait(create, issuer, true); + ValidateResult(created); + + string issuanceId = GetMPTokenIssuanceIdFromMeta(created); + Assert.IsNotNull(issuanceId, "MPTokenIssuanceID should be present in the metadata"); + return issuanceId; + } + + private static async Task ReadIssuance(string issuanceId) + { + LedgerEntryRequest request = new LedgerEntryRequest { MptIssuance = issuanceId }; + LedgerEntryResponse response = await client.LedgerEntry(request); + + Assert.IsNotNull(response?.Node, "ledger_entry should return the MPTokenIssuance node"); + Assert.IsInstanceOfType(response.Node, typeof(LOMPTokenIssuance), "Node should deserialize to LOMPTokenIssuance"); + return (LOMPTokenIssuance)response.Node; + } +} diff --git a/Tests/Xrpl.Tests/Models/RippledLedgerFlags.cs b/Tests/Xrpl.Tests/Models/RippledLedgerFlags.cs new file mode 100644 index 00000000..46a16d2a --- /dev/null +++ b/Tests/Xrpl.Tests/Models/RippledLedgerFlags.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; + +namespace Xrpl.Tests.Models.Tests +{ + /// + /// Reads the vendored rippled LedgerFormats.h — the only place the protocol states + /// which lsf flags belong to which ledger object. definitions.json and the + /// server_definitions RPC carry field codes and ledger entry types but no flag + /// values, so they cannot answer this question. + /// + /// + /// The source is C++ macro text, not a stability-guaranteed contract. Every parse step + /// fails loudly rather than yielding a thin or empty table — a silently empty result + /// would turn the conformance test green on nothing. + /// + internal static class RippledLedgerFlags + { + /// + /// LEDGER_ENTRY(ltNAME, 0x00, Name, ...) blocks are irrelevant here; the flags live in + /// LEDGER_OBJECT(Name, LSF_FLAG(lsfX, 0x…) …) blocks of the LEDGER_OBJECT_FLAGS list. + /// + private static readonly Regex ObjectBlock = new Regex( + @"LEDGER_OBJECT\(\s*(?\w+)\s*,(?(?:[^()]|\((?:[^()])*\))*)\)", + RegexOptions.Singleline | RegexOptions.Compiled); + + /// LSF_FLAG(lsfX, 0x00010000) / LSF_FLAG2(lsfX, 0x00000001) + private static readonly Regex FlagEntry = new Regex( + @"LSF_FLAG2?\(\s*(?ls[fm]\w+)\s*,\s*(?0x[0-9a-fA-F]+)\s*\)", + RegexOptions.Compiled); + + /// + /// Catches an LSF_FLAG variant the parser does not know yet — a new macro name would + /// otherwise drop its flags silently and leave the conformance test passing. + /// + private static readonly Regex AnyFlagMacro = new Regex( + @"(?LSF_FLAG\w*)\(", RegexOptions.Compiled); + + /// + /// Lower bounds on a healthy parse. Exposed so the guard test asserts against the same + /// numbers the parser enforces, instead of literals that would drift on re-pinning. + /// + internal const int MinimumExpectedObjects = 10; + + /// + internal const int MinimumExpectedFlags = 50; + + internal static string FixturePath => + Path.Combine(AppContext.BaseDirectory, "Fixtures", "LedgerFormats.h"); + + /// + /// Ledger object name -> flag name -> value, exactly as rippled declares it. + /// + internal static Dictionary> Parse() + { + if (!File.Exists(FixturePath)) + throw new InvalidOperationException($"Vendored LedgerFormats.h not found at {FixturePath}"); + + string header = File.ReadAllText(FixturePath); + if (string.IsNullOrWhiteSpace(header)) + throw new InvalidOperationException("Vendored LedgerFormats.h is empty"); + + foreach (Match macro in AnyFlagMacro.Matches(header)) + { + string name = macro.Groups["macro"].Value; + if (name is not ("LSF_FLAG" or "LSF_FLAG2")) + { + throw new InvalidOperationException( + $"Unknown flag macro '{name}' in LedgerFormats.h — the header layout changed, " + + "update the parser before trusting this test"); + } + } + + Dictionary> objects = new(); + int flagCount = 0; + + foreach (Match block in ObjectBlock.Matches(header)) + { + string name = block.Groups["name"].Value; + Dictionary flags = new(); + + foreach (Match flag in FlagEntry.Matches(block.Groups["body"].Value)) + { + flags[flag.Groups["flag"].Value] = + Convert.ToUInt32(flag.Groups["value"].Value.Substring(2), 16); + } + + // LEDGER_OBJECT is also used for objects that declare no flags at all; + // those carry nothing to conform to. + if (flags.Count == 0) + continue; + + objects[name] = flags; + flagCount += flags.Count; + } + + if (objects.Count < MinimumExpectedObjects || flagCount < MinimumExpectedFlags) + { + throw new InvalidOperationException( + $"Parsed only {objects.Count} flagged ledger objects / {flagCount} flags from " + + $"LedgerFormats.h (expected at least {MinimumExpectedObjects} / {MinimumExpectedFlags}) — " + + "the header layout changed and the parser silently stopped matching"); + } + + return objects; + } + } +} diff --git a/Tests/Xrpl.Tests/Models/TestULedgerFlagsConformance.cs b/Tests/Xrpl.Tests/Models/TestULedgerFlagsConformance.cs new file mode 100644 index 00000000..569b47ac --- /dev/null +++ b/Tests/Xrpl.Tests/Models/TestULedgerFlagsConformance.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; +using Xrpl.Models.Transactions; + +namespace Xrpl.Tests.Models.Tests +{ + /// + /// Holds the ledger-object flag enums to the values rippled declares in the vendored + /// LedgerFormats.h. + /// + /// + /// The counterpart of for the other half of the + /// protocol surface. Nothing else in the suite notices a missing flag: an unnamed bit still + /// arrives in the model as a number, so reading the object keeps working and only the + /// consumer's ability to test it by name is lost. That is how lsfMPTAMM — present since at + /// least 3.2.1 — went unnoticed until a manual diff found it. + /// + /// Pinned copy, not the live develop branch, for the same reason as the TxFormat guard: + /// tracking upstream drift is protocol-watch's job, and a network-backed test would go red + /// on Ripple's release schedule instead of ours. + /// + [TestClass] + public class TestULedgerFlagsConformance + { + /// + /// rippled LEDGER_OBJECT name -> the enum that names its flags in the models. + /// Every flagged object in the fixture must appear here; a new one fails the test + /// rather than being skipped silently. + /// + private static readonly Dictionary FlagEnums = new(StringComparer.Ordinal) + { + ["AccountRoot"] = typeof(AccountRootFlags), + ["Offer"] = typeof(OfferFlags), + ["RippleState"] = typeof(RippleStateFlags), + ["SignerList"] = typeof(SignerListFlags), + ["DirNode"] = typeof(DirectoryNodeFlags), + ["NFTokenOffer"] = typeof(NFTokenOffer), + ["MPTokenIssuance"] = typeof(MPTokenIssuanceFlags), + // rippled declares these as lsmf* ledger flags; TxFlags.h then aliases tmfX = lsmfX, + // and the SDK names them after the transaction side (MPTokenIssuanceCreate.MutableFlags) + ["MPTokenIssuanceMutable"] = typeof(MPTokenIssuanceCreateMutableFlags), + ["MPToken"] = typeof(MPTokenFlags), + ["Credential"] = typeof(CredentialFlags), + ["Vault"] = typeof(VaultLedgerFlags), + ["Loan"] = typeof(LoanFlags), + ["Sponsorship"] = typeof(SponsorshipFlags), + }; + + /// + /// Strips the prefix rippled and the models use for the same bit, so + /// lsfMPTLocked, MPTLocked and tmfMPTCanEnableCanLock compare + /// against their upstream counterparts. + /// + private static string Normalize(string name) => + Regex.Replace(name, "^(lsmf|lsf|tmf)", string.Empty); + + [TestMethod] + public void TestULedgerFlags_MatchRippledLedgerFormats() + { + Dictionary> upstream = RippledLedgerFlags.Parse(); + StringBuilder report = new StringBuilder(); + + foreach (KeyValuePair> entry in upstream.OrderBy(o => o.Key, StringComparer.Ordinal)) + { + if (!FlagEnums.TryGetValue(entry.Key, out Type flagEnum)) + { + report.AppendLine( + $"{entry.Key}: declares {entry.Value.Count} flag(s) in LedgerFormats.h but no model enum " + + "is registered for it — add the enum and register it in FlagEnums"); + continue; + } + + Dictionary mine = Enum.GetNames(flagEnum) + .ToDictionary( + name => Normalize(name), + name => Convert.ToUInt32(Enum.Parse(flagEnum, name)), + StringComparer.Ordinal); + + foreach (KeyValuePair flag in entry.Value.OrderBy(f => f.Key, StringComparer.Ordinal)) + { + string key = Normalize(flag.Key); + if (!mine.TryGetValue(key, out uint value)) + { + report.AppendLine( + $"{entry.Key}.{flag.Key} (0x{flag.Value:X8}): declared by rippled, " + + $"missing from {flagEnum.Name}"); + } + else if (value != flag.Value) + { + report.AppendLine( + $"{entry.Key}.{flag.Key}: rippled 0x{flag.Value:X8}, {flagEnum.Name} 0x{value:X8}"); + } + } + + // The other direction: a bit the models claim the protocol does not have. + // Zero members (None) carry no bit, and tf* members are transaction flags + // that share an enum with the ledger ones (OfferFlags.tfInnerBatchTxn). + HashSet declared = entry.Value.Keys.Select(Normalize).ToHashSet(StringComparer.Ordinal); + foreach (string name in Enum.GetNames(flagEnum).OrderBy(n => n, StringComparer.Ordinal)) + { + uint value = Convert.ToUInt32(Enum.Parse(flagEnum, name)); + if (value == 0 || name.StartsWith("tf", StringComparison.Ordinal) && !name.StartsWith("tmf", StringComparison.Ordinal)) + continue; + + if (!declared.Contains(Normalize(name))) + { + report.AppendLine( + $"{flagEnum.Name}.{name} (0x{value:X8}): in the models, " + + $"not a flag of {entry.Key} in rippled"); + } + } + } + + Assert.AreEqual( + string.Empty, + report.ToString(), + $"Ledger flag enums diverge from rippled LedgerFormats.h ({RippledLedgerFlags.FixturePath}):\n" + report); + } + + [TestMethod] + public void TestULedgerFlags_FixtureParsesFully() + { + Dictionary> upstream = RippledLedgerFlags.Parse(); + + Assert.IsTrue( + upstream.Count >= RippledLedgerFlags.MinimumExpectedObjects, + $"Parsed {upstream.Count} flagged ledger objects, expected at least {RippledLedgerFlags.MinimumExpectedObjects}"); + + int flags = upstream.Sum(o => o.Value.Count); + Assert.IsTrue( + flags >= RippledLedgerFlags.MinimumExpectedFlags, + $"Parsed {flags} flags, expected at least {RippledLedgerFlags.MinimumExpectedFlags}"); + + // A parse that yields objects but drops their values would still satisfy the counts + Assert.AreEqual( + 0x00000080u, + upstream["MPTokenIssuance"]["lsfMPTCanHoldConfidentialBalance"], + "lsfMPTCanHoldConfidentialBalance should parse to 0x80"); + } + } +} diff --git a/Tests/Xrpl.Tests/Xrpl.Tests.csproj b/Tests/Xrpl.Tests/Xrpl.Tests.csproj index f076abe6..39091527 100644 --- a/Tests/Xrpl.Tests/Xrpl.Tests.csproj +++ b/Tests/Xrpl.Tests/Xrpl.Tests.csproj @@ -33,6 +33,9 @@ PreserveNewest + + PreserveNewest + diff --git a/Xrpl/Models/Ledger/LODirectoryNode.cs b/Xrpl/Models/Ledger/LODirectoryNode.cs index 3a60528a..e6e21c43 100644 --- a/Xrpl/Models/Ledger/LODirectoryNode.cs +++ b/Xrpl/Models/Ledger/LODirectoryNode.cs @@ -7,6 +7,27 @@ namespace Xrpl.Models.Ledger { + /// + /// Flags of a DirectoryNode ledger object. + /// + /// + /// stays a raw uint for backwards compatibility; + /// test a bit with (dir.Flags & (uint)DirectoryNodeFlags.lsfNFTokenBuyOffers) != 0. + /// + [System.Flags] + public enum DirectoryNodeFlags : uint + { + /// + /// The directory holds buy offers for an NFToken. + /// + lsfNFTokenBuyOffers = 0x00000001, + + /// + /// The directory holds sell offers for an NFToken. + /// + lsfNFTokenSellOffers = 0x00000002, + } + /// /// The DirectoryNode object type provides a list of links to other objects in the ledger's state tree. /// @@ -19,8 +40,8 @@ public LODirectoryNode() } /// - /// A bit-map of boolean flags enabled for this directory.Currently, - /// the protocol defines no flags for DirectoryNode objects. + /// A bit-map of boolean flags enabled for this directory. + /// See for the values the protocol defines. /// public uint Flags { get; set; } /// diff --git a/Xrpl/Models/Ledger/LOLoan.cs b/Xrpl/Models/Ledger/LOLoan.cs index 43be63bd..e4f1bf1d 100644 --- a/Xrpl/Models/Ledger/LOLoan.cs +++ b/Xrpl/Models/Ledger/LOLoan.cs @@ -8,6 +8,28 @@ namespace Xrpl.Models.Ledger; +/// +/// Flags of a Loan ledger object. +/// +[Flags] +public enum LoanFlags : uint +{ + /// + /// The loan is in default: the borrower missed a payment past the grace period. + /// + lsfLoanDefault = 0x00010000, + + /// + /// The loan is impaired: the broker expects it not to be repaid in full. + /// + lsfLoanImpaired = 0x00020000, + + /// + /// The loan allows overpayments. + /// + lsfLoanOverpayment = 0x00040000, +} + /// /// A Loan ledger object represents a loan between a borrower and a loan broker. /// @@ -19,6 +41,12 @@ public LOLoan() LedgerEntryType = LedgerEntryType.Loan; } + /// + /// A bit-map of boolean flags enabled for this loan. + /// + [JsonPropertyName("Flags")] + public LoanFlags? Flags { get; init; } + /// /// The account address of the Borrower. /// diff --git a/Xrpl/Models/Ledger/LOMPToken.cs b/Xrpl/Models/Ledger/LOMPToken.cs index 25e96920..e46a0449 100644 --- a/Xrpl/Models/Ledger/LOMPToken.cs +++ b/Xrpl/Models/Ledger/LOMPToken.cs @@ -21,6 +21,11 @@ public enum MPTokenFlags : uint /// it can also be "un-set" using a MPTokenAuthorize transaction specifying the tfMPTUnauthorize flag. /// lsfMPTAuthorized = 2, + /// + /// If set, indicates that this MPToken belongs to an AMM pseudo-account.
+ /// AMMCreate sets it together with lsfMPTAuthorized to implicitly authorize the MPT asset for the pool. + ///
+ lsfMPTAMM = 4, } /// /// The MPToken object represents an amount of an MPT held by an account that is not the issuer. diff --git a/Xrpl/Models/Ledger/LOMPTokenIssuance.cs b/Xrpl/Models/Ledger/LOMPTokenIssuance.cs index a819cf5d..75689ef3 100644 --- a/Xrpl/Models/Ledger/LOMPTokenIssuance.cs +++ b/Xrpl/Models/Ledger/LOMPTokenIssuance.cs @@ -50,6 +50,12 @@ public enum MPTokenIssuanceFlags : uint /// Issuer can claw back balances from holders. /// MPTCanClawback = 0x00000040, + + /// + /// Holders can hold confidential (encrypted) balances of this MPT. + /// Requires ConfidentialTransfer amendment. + /// + MPTCanHoldConfidentialBalance = 0x00000080, } /// diff --git a/Xrpl/Models/Ledger/LOSignerList.cs b/Xrpl/Models/Ledger/LOSignerList.cs index 3ea1194c..e608fd65 100644 --- a/Xrpl/Models/Ledger/LOSignerList.cs +++ b/Xrpl/Models/Ledger/LOSignerList.cs @@ -9,6 +9,23 @@ namespace Xrpl.Models.Ledger; +/// +/// Flags of a SignerList ledger object. +/// +/// +/// stays a raw uint for backwards compatibility; +/// test a bit with (list.Flags & (uint)SignerListFlags.lsfOneOwnerCount) != 0. +/// +[Flags] +public enum SignerListFlags : uint +{ + /// + /// The signer list counts as one item against the owner reserve + /// rather than one per signer entry (set on every list created since MultiSignReserve). + /// + lsfOneOwnerCount = 0x00010000, +} + /// /// The SignerList object type represents a list of parties that, as a group, /// are authorized to sign a transaction in place of an individual account.
diff --git a/Xrpl/Xrpl.csproj b/Xrpl/Xrpl.csproj index d49f6712..07e16dea 100644 --- a/Xrpl/Xrpl.csproj +++ b/Xrpl/Xrpl.csproj @@ -1,4 +1,4 @@ - + @@ -14,7 +14,7 @@ Apache-2.0 https://github.com/StaticBit-io/XrplCSharp XrplCSharp - 10.10.1.0 + 10.11.0.0 From 76390415d317c4b42b93c8afd0b51a69b3dcf388 Mon Sep 17 00:00:00 2001 From: Aleksandr <44946855+Platonenkov@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:19:00 -0300 Subject: [PATCH 04/14] =?UTF-8?q?feat(protocol):=20sfLEVersion=20=E2=80=94?= =?UTF-8?q?=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F=20=D1=81=D1=85=D0=B5?= =?UTF-8?q?=D0=BC=D1=8B=20Vault=20(rippled=20#7817)=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(protocol): sfLEVersion — версия схемы Vault (rippled #7817) protocol-watch сообщил 03.08 об изменении sfields.macro и ledger_entries.macro на develop. Изменение одно: TYPED_SFIELD(sfLEVersion, UINT8, 6) и его появление в ltVAULT как SoeDefault (rippled #7817, cash-basis accounting для LoanBroker). Поле помечает, по какой схеме учёта живёт хранилище. Хранилища, созданные до активации cash-basis, не несут LEVersion вовсе, и rippled трактует его отсутствие как версию 0 (VaultVersion::Legacy), а не как ошибку — то есть отсутствие значения осмысленно, это не потеря данных. * definitions.json + сгенерированный Field.Uint8. Нужны ОБА: definitions.json в рантайме не читается, он вход для Tools/GenerateEnums, поэтому поле, добавленное только туда, не доезжает никуда. Файл перегенерирован инструментом, а не правился руками * LOVault.LEVersion (uint?, как остальные UInt8-поля этого объекта) и enum VaultVersion (Legacy = 0, CashBasis = 1) — имена для значений, которые протокол определяет на сегодня * TestULEVersion_BinaryRoundTrip — именно он показал, что одного definitions.json мало: до кодогенерации кодек поля не знал и тест падал * TestULOVault_LEVersion_Deserialize — обе формы: поле присутствует, и legacy- хранилище без него десериализуется в null Xrpl.BinaryCodec поднят до 10.10.0.0 (minor: добавлено поле). Остальные base-пакеты не тронуты и сохраняют версии. Xrpl не бампится: 10.11.0.0 ещё не выпущен и уже описывает модельные правки этой серии. Проверено: сборка решения без ошибок, юнит-тесты 877/877. * chore(release): выровнять версию Xrpl.BinaryCodec с Xrpl (10.11.0.0) Кодек везёт sfLEVersion, то есть меняется вместе с моделями, — держать его на собственной ветке нумерации незачем: потребитель читает один номер для обоих пакетов. Вместо следующего минора кодека (10.10.0.0) ставится 10.11.0.0. 10.10.x просто пропускается: последняя опубликованная версия Xrpl.BinaryCodec — 10.9.0, номер 10.10.0.0 на NuGet не выходил, так что ничего не переиспользуется и не переписывается. Xrpl.AddressCodec и Xrpl.Keypairs остаются на 10.9.0.0 — они не менялись. --- .../Enums/Field.Uint8.Generated.cs | 1 + Base/Xrpl.BinaryCodec/Enums/definitions.json | 10 +++++ Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj | 2 +- CHANGES.md | 6 +++ .../Models/TestUProtocolCompleteness.cs | 42 +++++++++++++++++++ Xrpl/Models/Ledger/LOVault.cs | 29 +++++++++++++ 6 files changed, 89 insertions(+), 1 deletion(-) diff --git a/Base/Xrpl.BinaryCodec/Enums/Field.Uint8.Generated.cs b/Base/Xrpl.BinaryCodec/Enums/Field.Uint8.Generated.cs index 28287e34..01a4ca2f 100644 --- a/Base/Xrpl.BinaryCodec/Enums/Field.Uint8.Generated.cs +++ b/Base/Xrpl.BinaryCodec/Enums/Field.Uint8.Generated.cs @@ -10,6 +10,7 @@ public partial class Field public static readonly Uint8Field Method = new Uint8Field(nameof(Method), 2); public static readonly Uint8Field Scale = new Uint8Field(nameof(Scale), 4); public static readonly Uint8Field AssetScale = new Uint8Field(nameof(AssetScale), 5); + public static readonly Uint8Field LEVersion = new Uint8Field(nameof(LEVersion), 6); public static readonly Uint8Field TickSize = new Uint8Field(nameof(TickSize), 16); public static readonly Uint8Field UNLModifyDisabling = new Uint8Field(nameof(UNLModifyDisabling), 17); public static readonly Uint8Field HookResult = new Uint8Field(nameof(HookResult), 18); diff --git a/Base/Xrpl.BinaryCodec/Enums/definitions.json b/Base/Xrpl.BinaryCodec/Enums/definitions.json index 0943aa48..14555c74 100644 --- a/Base/Xrpl.BinaryCodec/Enums/definitions.json +++ b/Base/Xrpl.BinaryCodec/Enums/definitions.json @@ -3150,6 +3150,16 @@ "type": "UInt8" } ], + [ + "LEVersion", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 6, + "type": "UInt8" + } + ], [ "TickSize", { diff --git a/Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj b/Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj index 770d5a8f..d38c8777 100644 --- a/Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj +++ b/Base/Xrpl.BinaryCodec/Xrpl.BinaryCodec.csproj @@ -13,7 +13,7 @@ Apache-2.0 https://github.com/StaticBit-io/XrplCSharp XrplCSharp - 10.9.0.0 + 10.11.0.0 diff --git a/CHANGES.md b/CHANGES.md index 4466a442..2cb96daa 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,12 @@ # Changes ## 10.11.0.0 08/03/2026 +* **`sfLEVersion` — the Vault ledger entry's schema version** ([rippled #7817](https://github.com/XRPLF/rippled/pull/7817), merged into `develop` 07/30/2026, reported by protocol-watch). `UInt8` nth 6, `SoeDefault` on `ltVAULT`: it marks which accounting scheme a vault follows. Vaults created before cash-basis accounting was activated carry no `LEVersion` at all, and rippled resolves that absence as version 0 rather than an error — so an absent value is meaningful, not missing data: + * `definitions.json` + the generated `Field.Uint8` entry. **Both are required**: `definitions.json` is not read at runtime, it is the input to `Tools/GenerateEnums`, so a field added there alone travels nowhere. `TestULEVersion_BinaryRoundTrip` is what proves the round trip actually works rather than that the JSON was edited + * `LOVault.LEVersion` (`uint?`, matching the other UInt8 fields of that object) plus a `VaultVersion` enum naming the two values the protocol defines so far (`Legacy` = 0, `CashBasis` = 1) + * `TestULOVault_LEVersion_Deserialize` covers both shapes — the field present, and a legacy vault without it deserializing to `null` + * `Xrpl.BinaryCodec` bumped to **10.11.0.0**, aligned with `Xrpl` rather than to its own next minor (10.10.0.0): the codec ships the field, so the two move together and a consumer can read one version number off both. 10.10.x is simply skipped — the codec's last published version is 10.9.0, so no number is being reused. `Xrpl.AddressCodec` and `Xrpl.Keypairs` are untouched and keep 10.9.0.0 + * **Ledger-object flags the protocol declares but the models never named** — an unnamed bit still arrives in the model as a number, so reading the object kept working and only the consumer's ability to test it by name was lost. That is why these went unnoticed; a field-by-field diff of rippled `LedgerFormats.h` (tag `3.3.0-rc1`) against every flag enum found four gaps: * `MPTokenIssuanceFlags` + **`MPTCanHoldConfidentialBalance`** (0x80) — introduced by ConfidentialTransfer. The rest of the amendment was already complete (transactions 85–89, `IssuerEncryptionKey`/`AuditorEncryptionKey`, `ConfidentialOutstandingAmount`); only the flag had no name. Value confirmed against a live node: `MPTokenIssuanceSet` with `MutableFlags = tmfMPTSetCanHoldConfidentialBalance` moves the issuance from `Flags = 0` to `Flags = 128` * `MPTokenFlags` + **`lsfMPTAMM`** (0x4) — a much older gap: the flag is present as far back as 3.2.1. `AMMCreate` sets it together with `lsfMPTAuthorized` to implicitly authorize an MPT asset for the AMM pseudo-account diff --git a/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs b/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs index 367382b5..1a27dbcc 100644 --- a/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs +++ b/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs @@ -322,5 +322,47 @@ public void TestULORippleState_SponsorFields_Deserialize() Assert.AreEqual(Account1, state.HighSponsor); Assert.AreEqual(Account2, state.LowSponsor); } + + [TestMethod] + public void TestULOVault_LEVersion_Deserialize() + { + string json = JsonSerializer.Serialize(new Dictionary + { + ["LedgerEntryType"] = "Vault", + ["Account"] = Account1, + ["Owner"] = Account2, + ["ShareMPTID"] = "00000001A407AF5856CCF3C42619DAA925813FC955C72983", + ["WithdrawalPolicy"] = 1, + ["Scale"] = 6, + ["LEVersion"] = (uint)VaultVersion.CashBasis, + }); + LOVault vault = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + Assert.AreEqual((uint)VaultVersion.CashBasis, vault.LEVersion); + + // A vault created before cash-basis accounting carries no LEVersion at all; + // rippled resolves that absence as VaultVersion.Legacy rather than an error + string legacy = JsonSerializer.Serialize(new Dictionary + { + ["LedgerEntryType"] = "Vault", + ["Account"] = Account1, + ["Owner"] = Account2, + }); + Assert.IsNull(JsonSerializer.Deserialize(legacy, XrplJsonOptions.Default).LEVersion); + } + + [TestMethod] + public void TestULEVersion_BinaryRoundTrip() + { + // The field only travels if definitions.json knows it — this fails with an + // encoding error, not an assertion, when the entry is missing. + // Parsed from text rather than built from int literals: that is the shape a + // node response arrives in, and Uint8.FromJson takes a byte, not an Int32 + JsonObject json = JsonNode.Parse("""{"LEVersion":1,"Scale":6}""")!.AsObject(); + string blob = XrplBinaryCodec.Encode(json); + JsonObject decoded = XrplBinaryCodec.Decode(blob).AsObject(); + + Assert.AreEqual(1u, decoded["LEVersion"]!.GetValue()); + Assert.AreEqual(6u, decoded["Scale"]!.GetValue()); + } } } diff --git a/Xrpl/Models/Ledger/LOVault.cs b/Xrpl/Models/Ledger/LOVault.cs index f50c08f8..ae7b15be 100644 --- a/Xrpl/Models/Ledger/LOVault.cs +++ b/Xrpl/Models/Ledger/LOVault.cs @@ -23,6 +23,27 @@ public enum VaultLedgerFlags : uint lsfVaultPrivate = 0x00010000, } +/// +/// Values of the Vault ledger entry's LEVersion field (rippled VaultVersion). +/// +/// +/// stays a plain uint?, matching the other UInt8 +/// fields of this object; these constants name the values the protocol defines so far. +/// +public enum VaultVersion : uint +{ + /// + /// Accrual-basis accounting. Vaults created before cash-basis accounting was activated + /// carry no LEVersion at all and are treated as this version implicitly. + /// + Legacy = 0, + + /// + /// Cash-basis accounting (rippled #7817). + /// + CashBasis = 1, +} + /// /// Recommended structure for the Vault Data field. /// The JSON is whitespace-removed and hex-encoded (max 256 bytes). @@ -144,6 +165,14 @@ public LOVault() [JsonPropertyName("Scale")] public uint? Scale { get; init; } + /// + /// Schema version of this ledger entry (UInt8), see . + /// Absent on vaults created before cash-basis accounting was activated, which + /// rippled resolves as (0) rather than an error. + /// + [JsonPropertyName("LEVersion")] + public uint? LEVersion { get; init; } + /// /// Arbitrary hex-encoded data associated with the vault, limited to 256 bytes. /// Use for a human-readable representation. From 713da1a5a7ebcd6a5f0787eccd8b6089639349b5 Mon Sep 17 00:00:00 2001 From: Aleksandr <44946855+Platonenkov@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:22:33 -0300 Subject: [PATCH 05/14] =?UTF-8?q?feat(models)!:=20=D0=B3=D0=B2=D0=B0=D1=80?= =?UTF-8?q?=D0=B4=20=D0=BF=D0=BE=D0=BB=D0=B5=D0=B9=20ledger-=D0=BE=D0=B1?= =?UTF-8?q?=D1=8A=D0=B5=D0=BA=D1=82=D0=BE=D0=B2=20=D0=B8=20=D1=87=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BA=D0=B0=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9=20=D0=BF=D0=BE=20ledger=5Fentries.macro=20(10.11.0.0)=20?= =?UTF-8?q?(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(models)!: гвард полей ledger-объектов и чистка моделей по ledger_entries.macro Третья и последняя поверхность конформанса: TestUTxFormatConformance держит поля транзакций, TestULedgerFlagsConformance — флаги ledger-объектов, а поля самих объектов не проверял никто. ledger_entries.macro — единственное место, где протокол объявляет состав объекта (definitions.json несёт коды полей и типы, но не списки), и пропущенное поле симптома не даёт: чтение объекта проходит, значение просто теряется. Гвард: * вендоренный Fixtures/ledger_entries.macro с пином по sha. Пин на develop, а не на тег (в отличие от LedgerFormats.h): модели по полям следуют develop, и sfLEVersion существует только после 30.07 — тег объявил бы его выдумкой SDK * диф в обе стороны + обязательная регистрация каждого объекта, поэтому новый ledger-объект роняет сборку, а не пропускается * четыре common-поля rippled (LedgerIndex, LedgerEntryType, Flags, Sponsor из LedgerFormats::getCommonFields()) исключаются с обеих сторон, как это уже сделано для commonFields в TxFormat-гварде; [JsonIgnore]-свойства не в счёт * проверен мутацией: переименование JsonPropertyName даёт обе половины отчёта Удалены свойства, которых нет в протоколе (BREAKING, без [Obsolete] — как при удалении инертных ConnectionOptions в 10.10.0.0). Значение они держать не могли: объект собирается по фиксированному SOTemplate. Проверено и на живой ноде, и по четырём версиям (3.2.1, 3.3.0-b1, 3.3.0-rc1, develop) — нет нигде: * LOVault.DomainID — positive control: VaultCreate с Data, AssetsMaximum и DomainID прошёл, первые два вернулись на объекте, DomainID — нет, он уехал на связанный share MPTokenIssuance * LOLoan.PrincipalRequested — поле транзакции LoanSet; реальный займ хранит сумму как PrincipalOutstanding * LOCredential.OwnerNode — у объекта IssuerNode/SubjectNode; нулевые hint'ы сериализуются ("OwnerNode":"0" у Loan), так что отсутствие настоящее * LONFTokenPage.NFTokenPage, LOAmm.LedgerCurrentIndex, LOAmm.Validated — последние два принадлежат конверту ответа amm_info (snake_case) Починено в LOAmm: * AMMAccount никогда не десериализовался — поле объекта называется Account, а атрибута не было; имя свойства не меняется, вызовы не ломаются * конструктор ставил LedgerEntryType.AccountRoot вместо AMM Добавлены PreviousTxnID/PreviousTxnLgrSeq в LOAmm, LOAmendments, LODirectoryNode, LOFeeSettings, LONegativeUNL. Xrpl 10.11.0.0 -> 10.12.0.0. Xrpl.BinaryCodec не менялся и остаётся 10.11.0.0. Проверено: юнит-тесты 879/879; на nightly-стенде TestILoan/TestIVault/ TestICredential/TestINFToken/TestIAmm — 64/64; сборка решения без ошибок. * chore(release): схлопнуть невыпущенные 10.10.1.0 / 10.11.0.0 / 10.12.0.0 в один 10.11.0.0 На NuGet последняя опубликованная версия Xrpl — 10.10.0. Значит 10.10.1.0 (фикс OnConnected), 10.11.0.0 (флаги ledger-объектов, гвард по LedgerFormats.h, DynamicMPT, sfLEVersion) и 10.12.0.0 (гвард полей ledger-объектов и чистка моделей) существуют только в dev и ни один из номеров наружу не уходил. Три раздела CHANGES.md слиты в один 10.11.0.0 от 08/04/2026, версия пакета Xrpl откачена с 10.12.0.0 на 10.11.0.0. Потребитель получит один релиз вместо трёх номеров, из которых два никогда не существовали как артефакты. Xrpl.BinaryCodec уже стоял на 10.11.0.0 — после схлопывания он совпадает с Xrpl ровно, как и задумывалось при выравнивании. AddressCodec и Keypairs остаются на 10.9.0.0: они не менялись. Проверено: сборка решения без ошибок, юнит-тесты 879/879. * refactor(review): нитпики CodeRabbit к PR #75 * LONegativeUNL: добавлен using System.Text.Json.Serialization и атрибуты сокращены до [JsonPropertyName] — как в LOAmendments, LODirectoryNode, LOFeeSettings и LOAmm. Полная форма стояла потому, что файл этот namespace не импортировал; теперь импортирует * RippledLedgerEntryFormats: парсер падает на повторно объявленном объекте вместо тихой перезаписи. Индексаторное присваивание позволяло второму объявлению вытеснить первое — объект молча уходил из таблицы конформанса, а fieldCount продолжал расти, так что порог минимального разбора потерю не замечал. Это ровно тот класс тихого отказа, против которого написан остальной парсер Дубликатов имён в текущей фикстуре нет (единственный LEDGER_ENTRY_DUPLICATE — DepositPreauth, и обычного объявления с этим именем не существует), так что гвард forward-looking. Проверен мутацией: дублирование блока Ticket даёт "Ticket: declared twice in ledger_entries.macro"; фикстура после проверки восстановлена и снова байт-в-байт совпадает с пином. Проверено: сборка без ошибок, юнит-тесты 879/879. --- CHANGES.md | 22 +- .../Xrpl.Tests/Fixtures/ledger_entries.macro | 641 ++++++++++++++++++ .../Fixtures/ledger_entries.macro.ref | 23 + .../Integration/transactions/TestILoan.cs | 8 +- .../Models/RippledLedgerEntryFormats.cs | 157 +++++ .../TestULedgerEntryFieldsConformance.cs | 181 +++++ Tests/Xrpl.Tests/Xrpl.Tests.csproj | 3 + Xrpl/Models/Ledger/LOAmendments.cs | 12 + Xrpl/Models/Ledger/LOAmm.cs | 29 +- Xrpl/Models/Ledger/LOCredential.cs | 6 - Xrpl/Models/Ledger/LODirectoryNode.cs | 12 + Xrpl/Models/Ledger/LOFeeSettings.cs | 12 + Xrpl/Models/Ledger/LOLoan.cs | 6 - Xrpl/Models/Ledger/LONFTokenPage.cs | 5 - Xrpl/Models/Ledger/LONegativeUNL.cs | 13 + Xrpl/Models/Ledger/LOVault.cs | 6 - 16 files changed, 1093 insertions(+), 43 deletions(-) create mode 100644 Tests/Xrpl.Tests/Fixtures/ledger_entries.macro create mode 100644 Tests/Xrpl.Tests/Fixtures/ledger_entries.macro.ref create mode 100644 Tests/Xrpl.Tests/Models/RippledLedgerEntryFormats.cs create mode 100644 Tests/Xrpl.Tests/Models/TestULedgerEntryFieldsConformance.cs diff --git a/CHANGES.md b/CHANGES.md index 2cb96daa..80ddaeee 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,25 @@ # Changes -## 10.11.0.0 08/03/2026 +## 10.11.0.0 08/04/2026 + +* **`TestULedgerEntryFieldsConformance` — the third conformance surface**, completing the set next to `TestUTxFormatConformance` (transaction fields) and `TestULedgerFlagsConformance` (ledger flags). `ledger_entries.macro` is the only place the protocol states which fields belong to which ledger object — `definitions.json` carries field codes and object types but not the per-object lists — and nothing checked it. A missing field produces no symptom: reading the object still succeeds and the value is silently dropped, which is how `LOAccountRoot` went without `WalletLocator`/`WalletSize` until a manual pass, and how `sfLEVersion` had to arrive through a protocol-watch notification instead of a red test: + * `Tests/Xrpl.Tests/Fixtures/ledger_entries.macro`, vendored byte-identical and pinned by sha in the `.ref`. Pinned to a **develop** commit rather than a tag, unlike `LedgerFormats.h`: the models track develop for fields, and `sfLEVersion` exists only after 07/30/2026, so a tag would report it as a field the SDK invented + * both directions are diffed — a field rippled declares and the model lacks, and a property the model exposes that is not a field of that object — and every ledger object must be registered against a model, so a newly added one fails the build instead of being skipped + * rippled's four **common fields** (`LedgerIndex`, `LedgerEntryType`, `Flags`, `Sponsor` from `LedgerFormats::getCommonFields()`) are excluded on both sides, mirroring how the TxFormat guard treats `commonFields`; `[JsonIgnore]` properties (computed helpers like `DataParsed`, `MPTokenMetadataRow`) never reach the wire and are excluded too + * verified by mutation: renaming a field's `JsonPropertyName` makes it report both halves (`Loan.Borrower … missing from LOLoan` and `LOLoan.BorrowerX … not a field of Loan`) + +* **Ledger-object properties that are not protocol fields — removed** (**breaking**, no `[Obsolete]` grace period, consistent with the 10.10.0.0 removal of the inert `ConnectionOptions`). None of them could ever hold a value: rippled builds each object from a fixed `SOTemplate`, so a field outside the template cannot appear in it. Confirmed against a live node (nightly stand, 3.3.0-b1) *and* across four rippled versions — 3.2.1, 3.3.0-b1, 3.3.0-rc1 and develop — none of these exists in any of them, including the unreleased one: + * `LOVault.DomainID` — proven with a positive control: a `VaultCreate` carrying `Data`, `AssetsMaximum` **and** `DomainID` succeeded, the first two came back on the object, `DomainID` did not, and it turned up on the linked share `MPTokenIssuance` instead — exactly what the macro comment (`no PermissionedDomainID ever (use MPTIssuance.sfDomainID)`) and `VaultCreate.cpp` (`.domainId = tx[~sfDomainID]`) describe + * `LOLoan.PrincipalRequested` — a field of the **LoanSet transaction**, not of the object: a real loan created with `PrincipalRequested = 10000000` stores it as `PrincipalOutstanding`, and the object carries no such field + * `LOCredential.OwnerNode` — Credential hangs in two directories and uses `IssuerNode`/`SubjectNode`. Zero-valued directory hints *are* serialized (a Loan object returns `"OwnerNode":"0"`), so its absence is real, not a default being omitted + * `LONFTokenPage.NFTokenPage`, `LOAmm.LedgerCurrentIndex`, `LOAmm.Validated` — the last two are fields of the `amm_info` **response envelope** (`ledger_current_index`, `validated`, snake_case), not of the AMM object; `LOAmm` is only ever deserialized as a ledger object, and `amm_info` has its own `AMMInfo` model + +* **`LOAmm` fixes** — two bugs the guard surfaced: + * **`AMMAccount` never deserialized**: the AMM object's field is `Account`, and the property had no `[JsonPropertyName]`, so it silently stayed null on every AMM object ever read. Now mapped to `Account`; the property name is unchanged, so no call site breaks + * the constructor set `LedgerEntryType = LedgerEntryType.AccountRoot` — an AMM object identified itself as an AccountRoot. Now `LedgerEntryType.AMM` + +* **Fields declared by the protocol but missing from the models** — `PreviousTxnID`/`PreviousTxnLgrSeq` on `LOAmm`, `LOAmendments`, `LODirectoryNode`, `LOFeeSettings` and `LONegativeUNL`. Both are `SoeOptional` on these objects upstream; without them the transaction that last touched the object could not be read through the typed API + * **`sfLEVersion` — the Vault ledger entry's schema version** ([rippled #7817](https://github.com/XRPLF/rippled/pull/7817), merged into `develop` 07/30/2026, reported by protocol-watch). `UInt8` nth 6, `SoeDefault` on `ltVAULT`: it marks which accounting scheme a vault follows. Vaults created before cash-basis accounting was activated carry no `LEVersion` at all, and rippled resolves that absence as version 0 rather than an error — so an absent value is meaningful, not missing data: * `definitions.json` + the generated `Field.Uint8` entry. **Both are required**: `definitions.json` is not read at runtime, it is the input to `Tools/GenerateEnums`, so a field added there alone travels nowhere. `TestULEVersion_BinaryRoundTrip` is what proves the round trip actually works rather than that the JSON was edited * `LOVault.LEVersion` (`uint?`, matching the other UInt8 fields of that object) plus a `VaultVersion` enum naming the two values the protocol defines so far (`Legacy` = 0, `CashBasis` = 1) @@ -29,7 +48,6 @@ * scenarios were derived from the transactor (`src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp` @ `3.3.0-rc1`), not from the docs — hence `tfMPTCanTransfer` at creation in the fee test: `preclaim` requires `lsfMPTCanTransfer` to be **already** set, and enabling it in the same transaction does not satisfy the rule * amendment-gated, so it skips on the CI stand (rippled 3.2.x has `DynamicMPT` as `Supported::No`) and runs for real on the nightly stand -## 10.10.1.0 08/02/2026 * **An exception from an `OnConnected` handler no longer kills the client forever** — `Connection.OnceOpen` caught anything thrown by a consumer `OnConnected` handler and called `Disconnect()`, i.e. the *user* disconnect path: it set `_permanentlyDisconnected = true` and called `ClearReconnectState()`. After that the client was dead — the reconnect loop was never restarted, no new socket was ever opened, `OnConnected` never fired again, and every later request threw `NotConnectedException("Client has been disconnected. Call Connect() to reconnect.")`. Nothing was logged and nothing was raised, so from the outside the client just went quiet: * **The trigger is the most ordinary event there is — a node restart.** `OnConnected` is the natural place to restore subscriptions, because the SDK does not restore them after a reconnect. A restarting node accepts TCP seconds before it starts answering requests, so the first `subscribe` after the reconnect runs into `RequestTimeout` (40 s) and throws. A consumer that lets the exception out — the reasonable "fail loudly, let the SDK reconnect" reaction — got the opposite: a silent, permanent death. Observed in production on a fleet of bots, each wedged for four hours after a node upgrade, one of them dying 69 seconds before the node came back * A failing handler is now treated as what it is — a **connection** failure, not a user disconnect. The socket is torn down and the regular reconnect loop takes over with its usual exponential backoff, exactly as for a transport failure. The permanent-disconnect flag is never set on this path diff --git a/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro b/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro new file mode 100644 index 00000000..b6408581 --- /dev/null +++ b/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro @@ -0,0 +1,641 @@ +#if !defined(LEDGER_ENTRY) +#error "undefined macro: LEDGER_ENTRY" +#endif + +#ifndef LEDGER_ENTRY_DUPLICATE +// The EXPAND macro is needed for Windows +// https://stackoverflow.com/questions/5134523/msvc-doesnt-expand-va-args-correctly +#define EXPAND(x) x + +// The `LEDGER_ENTRY_DUPLICATE macro is needed to avoid JSS conflicts +// Since some transactions and ledger entries have the same name (like `DepositPreauth`) +// The compiler won't accept two instances of `JSS(DepositPreauth)` +#define LEDGER_ENTRY_DUPLICATE(...) EXPAND(LEDGER_ENTRY(__VA_ARGS__)) +#endif + +/** + * These objects are listed in order of increasing ledger type ID. + * There are many gaps between these IDs. + * You are welcome to fill them with new object types. + */ + +/** A ledger object which identifies an offer to buy or sell an NFT. + + \sa keylet::nftokenOffer + */ +LEDGER_ENTRY(ltNFTOKEN_OFFER, 0x0037, NFTokenOffer, nft_offer, ({ + {sfOwner, SoeRequired}, + {sfNFTokenID, SoeRequired}, + {sfAmount, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfNFTokenOfferNode, SoeRequired}, + {sfDestination, SoeOptional}, + {sfExpiration, SoeOptional}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object which describes a check. + + \sa keylet::check + */ +LEDGER_ENTRY(ltCHECK, 0x0043, Check, check, ({ + {sfAccount, SoeRequired}, + {sfDestination, SoeRequired}, + {sfSendMax, SoeRequired}, + {sfSequence, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfDestinationNode, SoeRequired}, + {sfExpiration, SoeOptional}, + {sfInvoiceID, SoeOptional}, + {sfSourceTag, SoeOptional}, + {sfDestinationTag, SoeOptional}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** The ledger object which tracks the DID. + + \sa keylet::did +*/ +LEDGER_ENTRY(ltDID, 0x0049, DID, did, ({ + {sfAccount, SoeRequired}, + {sfDIDDocument, SoeOptional}, + {sfURI, SoeOptional}, + {sfData, SoeOptional}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** The ledger object which tracks the current negative UNL state. + + \note This is a singleton: only one such object exists in the ledger. + + \sa keylet::negativeUNL + */ +LEDGER_ENTRY(ltNEGATIVE_UNL, 0x004e, NegativeUNL, nunl, ({ + {sfDisabledValidators, SoeOptional}, + {sfValidatorToDisable, SoeOptional}, + {sfValidatorToReEnable, SoeOptional}, + {sfPreviousTxnID, SoeOptional}, + {sfPreviousTxnLgrSeq, SoeOptional}, +})) + +/** A ledger object which contains a list of NFTs + + \sa keylet::nftokenPageMin, keylet::nftokenPageMax, keylet::nftokenPage + */ +LEDGER_ENTRY(ltNFTOKEN_PAGE, 0x0050, NFTokenPage, nft_page, ({ + {sfPreviousPageMin, SoeOptional}, + {sfNextPageMin, SoeOptional}, + {sfNFTokens, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object which contains a signer list for an account. + + \sa keylet::signerList + */ +// All fields are SoeRequired because there is always a SignerEntries. +// If there are no SignerEntries the node is deleted. +LEDGER_ENTRY(ltSIGNER_LIST, 0x0053, SignerList, signer_list, ({ + {sfOwner, SoeOptional}, + {sfOwnerNode, SoeRequired}, + {sfSignerQuorum, SoeRequired}, + {sfSignerEntries, SoeRequired}, + {sfSignerListID, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object which describes a ticket. + + \sa keylet::ticket + */ +LEDGER_ENTRY(ltTICKET, 0x0054, Ticket, ticket, ({ + {sfAccount, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfTicketSequence, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object which describes an account. + + \sa keylet::account + */ +LEDGER_ENTRY(ltACCOUNT_ROOT, 0x0061, AccountRoot, account, ({ + {sfAccount, SoeRequired}, + {sfSequence, SoeRequired}, + {sfBalance, SoeRequired}, + {sfOwnerCount, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfAccountTxnID, SoeOptional}, + {sfRegularKey, SoeOptional}, + {sfEmailHash, SoeOptional}, + {sfWalletLocator, SoeOptional}, + {sfWalletSize, SoeOptional}, + {sfMessageKey, SoeOptional}, + {sfTransferRate, SoeOptional}, + {sfDomain, SoeOptional}, + {sfTickSize, SoeOptional}, + {sfTicketCount, SoeOptional}, + {sfNFTokenMinter, SoeOptional}, + {sfMintedNFTokens, SoeDefault}, + {sfBurnedNFTokens, SoeDefault}, + {sfFirstNFTokenSequence, SoeOptional}, + {sfSponsoredOwnerCount, SoeDefault}, + {sfSponsoringOwnerCount, SoeDefault}, + {sfSponsoringAccountCount, SoeDefault}, + {sfAMMID, SoeOptional}, // pseudo-account designator + {sfVaultID, SoeOptional}, // pseudo-account designator + {sfLoanBrokerID, SoeOptional}, // pseudo-account designator +})) + +/** A ledger object which contains a list of object identifiers. + + \sa keylet::page, keylet::quality, keylet::book, keylet::next and + keylet::ownerDir + */ +LEDGER_ENTRY(ltDIR_NODE, 0x0064, DirectoryNode, directory, ({ + {sfOwner, SoeOptional}, // for owner directories + {sfTakerPaysCurrency, SoeOptional}, // order book directories + {sfTakerPaysIssuer, SoeOptional}, // order book directories + {sfTakerPaysMPT, SoeOptional}, // order book directories + {sfTakerGetsCurrency, SoeOptional}, // order book directories + {sfTakerGetsIssuer, SoeOptional}, // order book directories + {sfTakerGetsMPT, SoeOptional}, // order book directories + {sfExchangeRate, SoeOptional}, // order book directories + {sfIndexes, SoeRequired}, + {sfRootIndex, SoeRequired}, + {sfIndexNext, SoeOptional}, + {sfIndexPrevious, SoeOptional}, + {sfNFTokenID, SoeOptional}, + {sfPreviousTxnID, SoeOptional}, + {sfPreviousTxnLgrSeq, SoeOptional}, + {sfDomainID, SoeOptional} // order book directories +})) + +/** The ledger object which lists details about amendments on the network. + + \note This is a singleton: only one such object exists in the ledger. + + \sa keylet::amendments + */ +LEDGER_ENTRY(ltAMENDMENTS, 0x0066, Amendments, amendments, ({ + {sfAmendments, SoeOptional}, // Enabled + {sfMajorities, SoeOptional}, + {sfPreviousTxnID, SoeOptional}, + {sfPreviousTxnLgrSeq, SoeOptional}, +})) + +/** A ledger object that contains a list of ledger hashes. + + This type is used to store the ledger hashes which the protocol uses + to implement skip lists that allow for efficient backwards (and, in + theory, forward) forward iteration across large ledger ranges. + + \sa keylet::skip + */ +LEDGER_ENTRY(ltLEDGER_HASHES, 0x0068, LedgerHashes, hashes, ({ + {sfFirstLedgerSequence, SoeOptional}, + {sfLastLedgerSequence, SoeOptional}, + {sfHashes, SoeRequired}, +})) + +/** The ledger object which lists details about sidechains. + + \sa keylet::bridge +*/ +LEDGER_ENTRY(ltBRIDGE, 0x0069, Bridge, bridge, ({ + {sfAccount, SoeRequired}, + {sfSignatureReward, SoeRequired}, + {sfMinAccountCreateAmount, SoeOptional}, + {sfXChainBridge, SoeRequired}, + {sfXChainClaimID, SoeRequired}, + {sfXChainAccountCreateCount, SoeRequired}, + {sfXChainAccountClaimCount, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object which describes an offer on the DEX. + + \sa keylet::offer + */ +LEDGER_ENTRY(ltOFFER, 0x006f, Offer, offer, ({ + {sfAccount, SoeRequired}, + {sfSequence, SoeRequired}, + {sfTakerPays, SoeRequired}, + {sfTakerGets, SoeRequired}, + {sfBookDirectory, SoeRequired}, + {sfBookNode, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfExpiration, SoeOptional}, + {sfDomainID, SoeOptional}, + {sfAdditionalBooks, SoeOptional}, +})) + +/** A ledger object which describes a deposit pre-authorization. + + \sa keylet::depositPreauth + */ +LEDGER_ENTRY_DUPLICATE(ltDEPOSIT_PREAUTH, 0x0070, DepositPreauth, deposit_preauth, ({ + {sfAccount, SoeRequired}, + {sfAuthorize, SoeOptional}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfAuthorizeCredentials, SoeOptional}, +})) + +/** A claim id for a cross chain transaction. + + \sa keylet::xChainClaimID +*/ +LEDGER_ENTRY(ltXCHAIN_OWNED_CLAIM_ID, 0x0071, XChainOwnedClaimID, xchain_owned_claim_id, ({ + {sfAccount, SoeRequired}, + {sfXChainBridge, SoeRequired}, + {sfXChainClaimID, SoeRequired}, + {sfOtherChainSource, SoeRequired}, + {sfXChainClaimAttestations, SoeRequired}, + {sfSignatureReward, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object which describes a bidirectional trust line. + + @note Per Vinnie Falco this should be renamed to ltTRUST_LINE + + \sa keylet::trustLine + */ +LEDGER_ENTRY(ltRIPPLE_STATE, 0x0072, RippleState, state, ({ + {sfBalance, SoeRequired}, + {sfLowLimit, SoeRequired}, + {sfHighLimit, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfLowNode, SoeOptional}, + {sfLowQualityIn, SoeOptional}, + {sfLowQualityOut, SoeOptional}, + {sfHighNode, SoeOptional}, + {sfHighQualityIn, SoeOptional}, + {sfHighQualityOut, SoeOptional}, + {sfHighSponsor, SoeOptional}, + {sfLowSponsor, SoeOptional}, +})) + +/** The ledger object which lists the network's fee settings. + + \note This is a singleton: only one such object exists in the ledger. + + \sa keylet::feeSettings + */ +LEDGER_ENTRY(ltFEE_SETTINGS, 0x0073, FeeSettings, fee, ({ + // Old version uses raw numbers + {sfBaseFee, SoeOptional}, + {sfReferenceFeeUnits, SoeOptional}, + {sfReserveBase, SoeOptional}, + {sfReserveIncrement, SoeOptional}, + // New version uses Amounts + {sfBaseFeeDrops, SoeOptional}, + {sfReserveBaseDrops, SoeOptional}, + {sfReserveIncrementDrops, SoeOptional}, + {sfPreviousTxnID, SoeOptional}, + {sfPreviousTxnLgrSeq, SoeOptional}, +})) + +/** A claim id for a cross chain create account transaction. + + \sa keylet::xChainCreateAccountClaimID +*/ +LEDGER_ENTRY(ltXCHAIN_OWNED_CREATE_ACCOUNT_CLAIM_ID, 0x0074, XChainOwnedCreateAccountClaimID, xchain_owned_create_account_claim_id, ({ + {sfAccount, SoeRequired}, + {sfXChainBridge, SoeRequired}, + {sfXChainAccountCreateCount, SoeRequired}, + {sfXChainCreateAccountAttestations, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object describing a single escrow. + + \sa keylet::escrow + */ +LEDGER_ENTRY(ltESCROW, 0x0075, Escrow, escrow, ({ + {sfAccount, SoeRequired}, + {sfSequence, SoeOptional}, + {sfDestination, SoeRequired}, + {sfAmount, SoeRequired}, + {sfCondition, SoeOptional}, + {sfCancelAfter, SoeOptional}, + {sfFinishAfter, SoeOptional}, + {sfSourceTag, SoeOptional}, + {sfDestinationTag, SoeOptional}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfDestinationNode, SoeOptional}, + {sfTransferRate, SoeOptional}, + {sfIssuerNode, SoeOptional}, +})) + +/** A ledger object describing a single unidirectional XRP payment channel. + + \sa keylet::payChannel + */ +LEDGER_ENTRY(ltPAYCHAN, 0x0078, PayChannel, payment_channel, ({ + {sfAccount, SoeRequired}, + {sfDestination, SoeRequired}, + {sfSequence, SoeOptional}, + {sfAmount, SoeRequired}, + {sfBalance, SoeRequired}, + {sfPublicKey, SoeRequired}, + {sfSettleDelay, SoeRequired}, + {sfExpiration, SoeOptional}, + {sfCancelAfter, SoeOptional}, + {sfSourceTag, SoeOptional}, + {sfDestinationTag, SoeOptional}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfDestinationNode, SoeOptional}, +})) + +/** The ledger object which tracks the AMM. + + \sa keylet::amm +*/ +LEDGER_ENTRY(ltAMM, 0x0079, AMM, amm, ({ + {sfAccount, SoeRequired}, + {sfTradingFee, SoeDefault}, + {sfVoteSlots, SoeOptional}, + {sfAuctionSlot, SoeOptional}, + {sfLPTokenBalance, SoeRequired}, + {sfAsset, SoeRequired}, + {sfAsset2, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeOptional}, + {sfPreviousTxnLgrSeq, SoeOptional}, +})) + +/** A ledger object which tracks MPTokenIssuance + \sa keylet::mptokenIssuance + */ +LEDGER_ENTRY(ltMPTOKEN_ISSUANCE, 0x007e, MPTokenIssuance, mpt_issuance, ({ + {sfIssuer, SoeRequired}, + {sfSequence, SoeRequired}, + {sfTransferFee, SoeDefault}, + {sfOwnerNode, SoeRequired}, + {sfAssetScale, SoeDefault}, + {sfMaximumAmount, SoeOptional}, + {sfOutstandingAmount, SoeRequired}, + {sfLockedAmount, SoeOptional}, + {sfMPTokenMetadata, SoeOptional}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfDomainID, SoeOptional}, + {sfMutableFlags, SoeDefault}, + {sfReferenceHolding, SoeOptional}, + {sfIssuerEncryptionKey, SoeOptional}, + {sfAuditorEncryptionKey, SoeOptional}, + {sfConfidentialOutstandingAmount, SoeDefault}, +})) + +/** A ledger object which tracks MPToken + \sa keylet::mptoken + */ +LEDGER_ENTRY(ltMPTOKEN, 0x007f, MPToken, mptoken, ({ + {sfAccount, SoeRequired}, + {sfMPTokenIssuanceID, SoeRequired}, + {sfMPTAmount, SoeDefault}, + {sfLockedAmount, SoeOptional}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfConfidentialBalanceInbox, SoeOptional}, + {sfConfidentialBalanceSpending, SoeOptional}, + {sfConfidentialBalanceVersion, SoeDefault}, + {sfIssuerEncryptedBalance, SoeOptional}, + {sfAuditorEncryptedBalance, SoeOptional}, + {sfHolderEncryptionKey, SoeOptional}, +})) + +/** A ledger object which tracks Oracle + \sa keylet::oracle + */ +LEDGER_ENTRY(ltORACLE, 0x0080, Oracle, oracle, ({ + {sfOwner, SoeRequired}, + {sfOracleDocumentID, SoeOptional}, + {sfProvider, SoeRequired}, + {sfPriceDataSeries, SoeRequired}, + {sfAssetClass, SoeRequired}, + {sfLastUpdateTime, SoeRequired}, + {sfURI, SoeOptional}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object which tracks Credential + \sa keylet::credential + */ +LEDGER_ENTRY(ltCREDENTIAL, 0x0081, Credential, credential, ({ + {sfSubject, SoeRequired}, + {sfIssuer, SoeRequired}, + {sfCredentialType, SoeRequired}, + {sfExpiration, SoeOptional}, + {sfURI, SoeOptional}, + {sfIssuerNode, SoeRequired}, + {sfSubjectNode, SoeOptional}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object which tracks PermissionedDomain + \sa keylet::permissionedDomain + */ +LEDGER_ENTRY(ltPERMISSIONED_DOMAIN, 0x0082, PermissionedDomain, permissioned_domain, ({ + {sfOwner, SoeRequired}, + {sfSequence, SoeRequired}, + {sfAcceptedCredentials, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object representing permissions an account has delegated to another account. + \sa keylet::delegate + */ +LEDGER_ENTRY(ltDELEGATE, 0x0083, Delegate, delegate, ({ + {sfAccount, SoeRequired}, + {sfAuthorize, SoeRequired}, + {sfPermissions, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfDestinationNode, SoeOptional}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object representing a single asset vault. + \sa keylet::vault + */ +LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({ + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfSequence, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfOwner, SoeRequired}, + {sfAccount, SoeRequired}, + {sfData, SoeOptional}, + {sfAsset, SoeRequired}, + {sfAssetsTotal, SoeDefault}, + {sfAssetsAvailable, SoeDefault}, + {sfAssetsMaximum, SoeDefault}, + {sfLossUnrealized, SoeDefault}, + {sfShareMPTID, SoeRequired}, + {sfWithdrawalPolicy, SoeRequired}, + {sfScale, SoeDefault}, + {sfLEVersion, SoeDefault}, + // no SharesTotal ever (use MPTIssuance.sfOutstandingAmount) + // no PermissionedDomainID ever (use MPTIssuance.sfDomainID) +})) + +/** Reserve 0x0084-0x0087 for future Vault-related objects. */ + +/** A ledger object representing a loan broker + + \sa keylet::loanBroker + */ +LEDGER_ENTRY(ltLOAN_BROKER, 0x0088, LoanBroker, loan_broker, ({ + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfSequence, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfVaultNode, SoeRequired}, + {sfVaultID, SoeRequired}, + {sfAccount, SoeRequired}, + {sfOwner, SoeRequired}, + {sfLoanSequence, SoeRequired}, + {sfData, SoeDefault}, + {sfManagementFeeRate, SoeDefault}, + {sfOwnerCount, SoeDefault}, + {sfDebtTotal, SoeDefault}, + {sfDebtMaximum, SoeDefault}, + {sfCoverAvailable, SoeDefault}, + {sfCoverRateMinimum, SoeDefault}, + {sfCoverRateLiquidation, SoeDefault}, +})) + +/** A ledger object representing a loan between a Borrower and a Loan Broker + + \sa keylet::loan + */ +LEDGER_ENTRY(ltLOAN, 0x0089, Loan, loan, ({ + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfOwnerNode, SoeRequired}, + {sfLoanBrokerNode, SoeRequired}, + {sfLoanBrokerID, SoeRequired}, + {sfLoanSequence, SoeRequired}, + {sfBorrower, SoeRequired}, + {sfLoanOriginationFee, SoeDefault}, + {sfLoanServiceFee, SoeDefault}, + {sfLatePaymentFee, SoeDefault}, + {sfClosePaymentFee, SoeDefault}, + {sfOverpaymentFee, SoeDefault}, + {sfInterestRate, SoeDefault}, + {sfLateInterestRate, SoeDefault}, + {sfCloseInterestRate, SoeDefault}, + {sfOverpaymentInterestRate, SoeDefault}, + {sfStartDate, SoeRequired}, + {sfPaymentInterval, SoeRequired}, + {sfGracePeriod, SoeDefault}, + {sfPreviousPaymentDueDate, SoeDefault}, + {sfNextPaymentDueDate, SoeDefault}, + // The loan object tracks these values: + // + // - PaymentRemaining: The number of payments left in the loan. When it + // reaches 0, the loan is paid off, and all other relevant values + // must also be 0. + // + // - PeriodicPayment: The fixed, unrounded amount to be paid each + // interval. Stored with as much precision as possible. + // Payment transactions must round this value *UP*. + // + // - TotalValueOutstanding: The rounded total amount owed by the + // borrower to the lender / vault. + // + // - PrincipalOutstanding: The rounded portion of the + // TotalValueOutstanding that is from the principal borrowed. + // + // - ManagementFeeOutstanding: The rounded portion of the + // TotalValueOutstanding that represents management fees + // specifically owed to the broker based on the initial + // loan parameters. + // + // There are additional values that can be computed from these: + // + // - InterestOutstanding = TotalValueOutstanding - PrincipalOutstanding + // The total amount of interest still pending on the loan, + // independent of management fees. + // + // - InterestOwedToVault = InterestOutstanding - ManagementFeeOutstanding + // The amount of the total interest that is owed to the vault, and + // will be sent to it as part of a payment. + // + // - TrueTotalLoanValue = PaymentRemaining * PeriodicPayment + // The unrounded true total value of the loan. + // + // - TrueTotalPrincipalOutstanding can be computed using the algorithm + // in the xrpl::detail::loanPrincipalFromPeriodicPayment function. + // + // - TrueTotalInterestOutstanding = TrueTotalLoanValue - + // TrueTotalPrincipalOutstanding + // The unrounded true total interest remaining. + // + // - TrueTotalManagementFeeOutstanding = TrueTotalInterestOutstanding * + // LoanBroker.ManagementFeeRate + // The unrounded true total fee still owed to the broker. + // + // Note the "True" values may differ significantly from the tracked + // rounded values. + {sfPaymentRemaining, SoeDefault}, + {sfPeriodicPayment, SoeRequired}, + {sfPrincipalOutstanding, SoeDefault}, + {sfTotalValueOutstanding, SoeDefault}, + {sfManagementFeeOutstanding, SoeDefault}, + // Based on the computed total value at creation, used for + // rounding calculated values so they are all on a + // consistent scale - that is, they all have the same + // number of digits after the decimal point (excluding + // trailing zeros). + {sfLoanScale, SoeDefault}, +})) + +/** A ledger object representing a sponsorship. + \sa keylet::sponsorship + */ +LEDGER_ENTRY(ltSPONSORSHIP, 0x0090, Sponsorship, sponsorship, ({ + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, + {sfOwner, SoeRequired}, + {sfSponsee, SoeRequired}, + {sfFeeAmount, SoeOptional}, + {sfMaxFee, SoeOptional}, + {sfRemainingOwnerCount, SoeDefault}, + {sfOwnerNode, SoeRequired}, + {sfSponseeNode, SoeRequired}, +})) + +#undef EXPAND +#undef LEDGER_ENTRY_DUPLICATE diff --git a/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro.ref b/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro.ref new file mode 100644 index 00000000..c88bbc16 --- /dev/null +++ b/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro.ref @@ -0,0 +1,23 @@ +https://github.com/XRPLF/rippled/blob/develop/include/xrpl/protocol/detail/ledger_entries.macro +sha ecdd457f3598c7286a9af4aff358fbd30039173f +date 2026-07-30T23:04:38Z + +ledger_entries.macro is vendored byte-identical to the ref above so that it can be +re-verified with a plain diff: + + curl -sSL https://raw.githubusercontent.com/XRPLF/rippled/ecdd457f3598c7286a9af4aff358fbd30039173f/include/xrpl/protocol/detail/ledger_entries.macro \ + | diff - Tests/Xrpl.Tests/Fixtures/ledger_entries.macro + +This is the only place the protocol states which fields belong to which ledger +object: definitions.json carries field codes and object types, but not the +per-object field lists. + +Pinned to a develop commit rather than to a release tag — unlike LedgerFormats.h, +which is pinned to 3.3.0-rc1 because the two are identical there. The models track +develop for fields: sfLEVersion (Vault) exists only after 07/30/2026 and is absent +from 3.3.0-rc1, so a tag would report it as a field the models invented. This sha +is the one protocol-watch recorded when it reported the change. + +Do not hand-edit it. When protocol-watch reports a change to this file upstream, +replace it wholesale, update the sha above, and let TestULedgerEntryFieldsConformance +show which models have to follow. diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestILoan.cs b/Tests/Xrpl.Tests/Integration/transactions/TestILoan.cs index 8544dc8d..63f57c37 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestILoan.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestILoan.cs @@ -336,11 +336,9 @@ public async Task TestLoanLedgerEntry_VerifyFields() Assert.IsNotNull(loan.LoanBrokerID, "LoanBrokerID should be set"); Assert.IsNotNull(loan.LoanSequence, "LoanSequence should be set"); - // Number fields — PrincipalRequested was explicitly set to "10000000" in LoanSet, - // but rippled may omit zero-value Number fields. - // PrincipalOutstanding may be null if no payments have been made yet (depends on rippled behavior). - if (loan.PrincipalRequested != null) - Assert.IsTrue(loan.PrincipalRequested.Length > 0, "PrincipalRequested should be non-empty if present"); + // PrincipalRequested is a field of the LoanSet TRANSACTION, not of the Loan object: + // rippled records the amount as PrincipalOutstanding, so the object never carries it + // (confirmed against a live node — the created object holds PrincipalOutstanding only). if (loan.PrincipalOutstanding != null) Assert.IsTrue(loan.PrincipalOutstanding.Length > 0, "PrincipalOutstanding should be non-empty if present"); diff --git a/Tests/Xrpl.Tests/Models/RippledLedgerEntryFormats.cs b/Tests/Xrpl.Tests/Models/RippledLedgerEntryFormats.cs new file mode 100644 index 00000000..ec4b9ee8 --- /dev/null +++ b/Tests/Xrpl.Tests/Models/RippledLedgerEntryFormats.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; + +namespace Xrpl.Tests.Models.Tests +{ + /// + /// Reads the vendored rippled ledger_entries.macro — the only place the protocol + /// states which fields belong to which ledger object. definitions.json carries field + /// codes and object types, but not the per-object field lists, so it cannot answer this. + /// + /// + /// The counterpart of for ledger objects. Same + /// contract: the source is C++ macro text, so every parse step fails loudly rather than + /// yielding a thin or empty table — a silently empty result would turn the conformance + /// test green on nothing. + /// + internal static class RippledLedgerEntryFormats + { + /// + /// LEDGER_ENTRY(ltTAG, 0x00NN, Name, rpcName, ({ {sfField, SoeX}, ... })) + /// LEDGER_ENTRY_DUPLICATE(...) has the same shape and declares an object that shares + /// a type code with another one, so it is parsed identically. + /// + private static readonly Regex EntryBlock = new Regex( + @"LEDGER_ENTRY(?:_DUPLICATE)?\(\s*lt\w+\s*,\s*0x[0-9a-fA-F]+\s*,\s*(?\w+)\s*,(?.*?)\}\)\)", + RegexOptions.Singleline | RegexOptions.Compiled); + + /// {sfField, SoeRequired} / {sfField, SoeOptional} / {sfField, SoeDefault} + private static readonly Regex FieldEntry = new Regex( + @"\{\s*sf(?\w+)\s*,\s*Soe(?Required|Optional|Default)\b", + RegexOptions.Compiled); + + /// Catches a requirement keyword the mapping below does not know yet. + private static readonly Regex AnyFieldEntry = new Regex( + @"\{\s*sf(?\w+)\s*,\s*Soe(?\w+)", + RegexOptions.Compiled); + + /// + /// Lower bounds on a healthy parse, asserted by the guard test as well so the two + /// cannot disagree about what "parsed enough" means. + /// + internal const int MinimumExpectedEntries = 25; + + /// + internal const int MinimumExpectedFields = 250; + + internal static string FixturePath => + Path.Combine(AppContext.BaseDirectory, "Fixtures", "ledger_entries.macro"); + + /// + /// Fields every ledger object carries, declared once in rippled's + /// LedgerFormats::getCommonFields() (src/libxrpl/protocol/LedgerFormats.cpp) + /// rather than per object in the macro — the ledger-side counterpart of + /// TxFormats' commonFields. Both directions of the conformance diff + /// exclude them: the macro never lists them, so requiring them of a model would be + /// wrong, and a model that does expose them is not inventing anything. + /// + /// + /// Hand-maintained: the list lives in a .cpp, which protocol-watch does not track + /// (it watches the headers and macros). It has held these four for releases — + /// sfSponsor was the last addition, with XLS-68 — so drift here is slow and + /// visible: a new common field would surface as the same name reported missing from + /// every single model at once. + /// + internal static HashSet CommonFields() => new(StringComparer.Ordinal) + { + "LedgerIndex", + "LedgerEntryType", + "Flags", + "Sponsor", + }; + + /// How rippled declares a field of a ledger object. + internal enum Requirement + { + /// Always present. + Required, + + /// May be absent. + Optional, + + /// Absent means the type's default value, not missing data. + Default, + } + + /// + /// Ledger object name -> field name -> requirement, exactly as rippled declares it. + /// + internal static Dictionary> Parse() + { + if (!File.Exists(FixturePath)) + throw new InvalidOperationException($"Vendored ledger_entries.macro not found at {FixturePath}"); + + string macro = File.ReadAllText(FixturePath); + if (string.IsNullOrWhiteSpace(macro)) + throw new InvalidOperationException("Vendored ledger_entries.macro is empty"); + + Dictionary> entries = new(); + int fieldCount = 0; + + foreach (Match block in EntryBlock.Matches(macro)) + { + string name = block.Groups["name"].Value; + string body = block.Groups["body"].Value; + + foreach (Match raw in AnyFieldEntry.Matches(body)) + { + string keyword = raw.Groups["requirement"].Value; + if (keyword is not ("Required" or "Optional" or "Default")) + { + throw new InvalidOperationException( + $"{name}.{raw.Groups["field"].Value}: unknown requirement keyword 'Soe{keyword}' — " + + "the macro format changed, update the parser before trusting this test"); + } + } + + Dictionary fields = new(); + foreach (Match field in FieldEntry.Matches(body)) + { + fields[field.Groups["field"].Value] = field.Groups["requirement"].Value switch + { + "Required" => Requirement.Required, + "Optional" => Requirement.Optional, + "Default" => Requirement.Default, + _ => throw new InvalidOperationException("unreachable"), + }; + } + + // Indexer assignment would let a second declaration of the same name replace + // the first, dropping that object from the conformance table while the field + // count below still grew — the minimum-count guard would not notice + if (entries.ContainsKey(name)) + { + throw new InvalidOperationException( + $"{name}: declared twice in ledger_entries.macro — the parser would drop one " + + "definition, update it before trusting this test"); + } + + entries.Add(name, fields); + fieldCount += fields.Count; + } + + if (entries.Count < MinimumExpectedEntries || fieldCount < MinimumExpectedFields) + { + throw new InvalidOperationException( + $"Parsed only {entries.Count} ledger entries / {fieldCount} fields from " + + $"ledger_entries.macro (expected at least {MinimumExpectedEntries} / " + + $"{MinimumExpectedFields}) — the macro layout changed and the parser " + + "silently stopped matching"); + } + + return entries; + } + } +} diff --git a/Tests/Xrpl.Tests/Models/TestULedgerEntryFieldsConformance.cs b/Tests/Xrpl.Tests/Models/TestULedgerEntryFieldsConformance.cs new file mode 100644 index 00000000..74e675d0 --- /dev/null +++ b/Tests/Xrpl.Tests/Models/TestULedgerEntryFieldsConformance.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.Json.Serialization; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Models.Ledger; + +using LONFTokenOffer = Xrpl.Models.Methods.LONFTokenOffer; +using LONFTokenPage = Xrpl.Models.Methods.LONFTokenPage; + +namespace Xrpl.Tests.Models.Tests +{ + /// + /// Holds the ledger-object models to the field sets rippled declares in the vendored + /// ledger_entries.macro. + /// + /// + /// The third conformance surface, next to (transaction + /// fields) and (ledger flags). A field the protocol + /// declares and the model lacks produces no symptom: reading the object still succeeds and the + /// value is simply dropped, so it stays invisible until someone needs it. That is how + /// LOAccountRoot went without WalletLocator/WalletSize until a manual completeness pass, and + /// how sfLEVersion had to be spotted through a protocol-watch notification instead of a red test. + /// + [TestClass] + public class TestULedgerEntryFieldsConformance + { + /// + /// rippled LEDGER_ENTRY name -> the model that carries its fields. Every entry in the + /// fixture must appear here; a newly added ledger object fails the test rather than + /// being skipped silently. + /// + private static readonly Dictionary Models = new(StringComparer.Ordinal) + { + ["AccountRoot"] = typeof(LOAccountRoot), + ["AMM"] = typeof(LOAmm), + ["Amendments"] = typeof(LOAmendments), + ["Bridge"] = typeof(LOBridge), + ["Check"] = typeof(LOCheck), + ["Credential"] = typeof(LOCredential), + ["Delegate"] = typeof(LODelegate), + ["DepositPreauth"] = typeof(LODepositPreauth), + ["DID"] = typeof(LODID), + ["DirectoryNode"] = typeof(LODirectoryNode), + ["Escrow"] = typeof(LOEscrow), + ["FeeSettings"] = typeof(LOFeeSettings), + ["LedgerHashes"] = typeof(LOLedgerHashes), + ["Loan"] = typeof(LOLoan), + ["LoanBroker"] = typeof(LOLoanBroker), + ["MPToken"] = typeof(LOMPToken), + ["MPTokenIssuance"] = typeof(LOMPTokenIssuance), + ["NegativeUNL"] = typeof(LONegativeUNL), + ["NFTokenOffer"] = typeof(LONFTokenOffer), + ["NFTokenPage"] = typeof(LONFTokenPage), + ["Offer"] = typeof(LOOffer), + ["Oracle"] = typeof(LOOracle), + ["PayChannel"] = typeof(LOPayChannel), + ["PermissionedDomain"] = typeof(LOPermissionedDomain), + ["RippleState"] = typeof(LORippleState), + ["SignerList"] = typeof(LOSignerList), + ["Sponsorship"] = typeof(LOSponsorship), + ["Ticket"] = typeof(LOTicket), + ["Vault"] = typeof(LOVault), + ["XChainOwnedClaimID"] = typeof(LOXChainOwnedClaimID), + ["XChainOwnedCreateAccountClaimID"] = typeof(LOXChainOwnedCreateAccountClaimID), + }; + + /// + /// Names that appear on a model but are not fields of that ledger object, with the + /// reason each is legitimate. Anything else the reverse check reports is a real finding. + /// Common fields are handled separately, via + /// . + /// + private static readonly Dictionary KnownExtras = new(StringComparer.Ordinal) + { + // BaseLedgerEntry.Index, serialized as "index" — the object's own key. rippled + // returns it alongside the object (account_objects, ledger_entry) and it is not + // part of any object's template + ["index"] = "the entry's key, returned beside the object rather than inside it", + }; + + /// + /// The JSON name a property maps to: when + /// present, the property name otherwise. Properties marked + /// never reach the wire and are excluded — that is where the computed helpers live + /// (DataParsed, MPTokenMetadataRow, Metadata, …). + /// + private static Dictionary WireProperties(Type model) + { + Dictionary map = new(StringComparer.Ordinal); + + foreach (PropertyInfo property in model.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (property.GetCustomAttribute() != null) + continue; + + string name = property.GetCustomAttribute()?.Name ?? property.Name; + map[name] = property; + } + + return map; + } + + [TestMethod] + public void TestULedgerEntryModels_MatchRippledLedgerEntriesMacro() + { + Dictionary> upstream = + RippledLedgerEntryFormats.Parse(); + HashSet common = RippledLedgerEntryFormats.CommonFields(); + StringBuilder report = new StringBuilder(); + + foreach (KeyValuePair> entry + in upstream.OrderBy(e => e.Key, StringComparer.Ordinal)) + { + if (!Models.TryGetValue(entry.Key, out Type model)) + { + report.AppendLine( + $"{entry.Key}: declared in ledger_entries.macro but no model is registered for it — " + + "add the LO type and register it in Models"); + continue; + } + + Dictionary mine = WireProperties(model); + + foreach (string field in entry.Value.Keys.OrderBy(f => f, StringComparer.Ordinal)) + { + if (!mine.ContainsKey(field)) + { + report.AppendLine( + $"{entry.Key}.{field} ({entry.Value[field]}): declared by rippled, " + + $"missing from {model.Name}"); + } + } + + foreach (string name in mine.Keys.OrderBy(n => n, StringComparer.Ordinal)) + { + if (entry.Value.ContainsKey(name) || common.Contains(name) || KnownExtras.ContainsKey(name)) + continue; + + report.AppendLine( + $"{model.Name}.{name}: on the model, not a field of {entry.Key} in rippled"); + } + } + + Assert.AreEqual( + string.Empty, + report.ToString(), + $"Ledger-object models diverge from rippled ledger_entries.macro ({RippledLedgerEntryFormats.FixturePath}):\n" + report); + } + + [TestMethod] + public void TestULedgerEntryFixture_ParsesFully() + { + Dictionary> upstream = + RippledLedgerEntryFormats.Parse(); + + Assert.IsTrue( + upstream.Count >= RippledLedgerEntryFormats.MinimumExpectedEntries, + $"Parsed {upstream.Count} ledger entries, expected at least {RippledLedgerEntryFormats.MinimumExpectedEntries}"); + + int fields = upstream.Sum(e => e.Value.Count); + Assert.IsTrue( + fields >= RippledLedgerEntryFormats.MinimumExpectedFields, + $"Parsed {fields} fields, expected at least {RippledLedgerEntryFormats.MinimumExpectedFields}"); + + // Counts alone would still pass on a parse that dropped requirements + Assert.AreEqual( + RippledLedgerEntryFormats.Requirement.Default, + upstream["Vault"]["LEVersion"], + "Vault.LEVersion should parse as SoeDefault"); + Assert.AreEqual( + RippledLedgerEntryFormats.Requirement.Required, + upstream["Vault"]["Owner"], + "Vault.Owner should parse as SoeRequired"); + } + } +} diff --git a/Tests/Xrpl.Tests/Xrpl.Tests.csproj b/Tests/Xrpl.Tests/Xrpl.Tests.csproj index 39091527..72fd5fa9 100644 --- a/Tests/Xrpl.Tests/Xrpl.Tests.csproj +++ b/Tests/Xrpl.Tests/Xrpl.Tests.csproj @@ -36,6 +36,9 @@ PreserveNewest + + PreserveNewest + diff --git a/Xrpl/Models/Ledger/LOAmendments.cs b/Xrpl/Models/Ledger/LOAmendments.cs index 688c3f7a..7ee5ad8e 100644 --- a/Xrpl/Models/Ledger/LOAmendments.cs +++ b/Xrpl/Models/Ledger/LOAmendments.cs @@ -43,6 +43,18 @@ public LOAmendments() /// No flags are defined for the Amendments object type, so this value is always 0. /// public uint Flags { get; set; } + + /// + /// The identifying hash of the transaction that most recently modified this object. + /// + [JsonPropertyName("PreviousTxnID")] + public string PreviousTxnID { get; set; } + + /// + /// The index of the ledger that contains the transaction that most recently modified this object. + /// + [JsonPropertyName("PreviousTxnLgrSeq")] + public uint? PreviousTxnLgrSeq { get; set; } } public class Majority diff --git a/Xrpl/Models/Ledger/LOAmm.cs b/Xrpl/Models/Ledger/LOAmm.cs index bcb7ead1..67d3b284 100644 --- a/Xrpl/Models/Ledger/LOAmm.cs +++ b/Xrpl/Models/Ledger/LOAmm.cs @@ -12,11 +12,13 @@ public class LOAmm : BaseLedgerEntry { public LOAmm() { - LedgerEntryType = LedgerEntryType.AccountRoot; + LedgerEntryType = LedgerEntryType.AMM; } /// - /// The account that tracks the balance of LPTokens between the AMM instance via Trustline. + /// The special account that holds the AMM's assets and issues its LPTokens. + /// Serialized as Account, which is the name rippled gives this field. /// + [JsonPropertyName("Account")] public string AMMAccount { get; set; } /// /// Specifies one of the pool assets (XRP or token) of the AMM instance. @@ -53,21 +55,22 @@ public LOAmm() /// A list of vote objects, representing votes on the pool's trading fee.. /// public List VoteSlots { get; set; } - /// - /// The ledger index of the current in-progress ledger, which was used when - /// retrieving this information. - /// - public int? LedgerCurrentIndex { get; set; } - /// - /// True if this data is from a validated ledger version;
- /// if omitted or set to false, this data is not final. - ///
- public bool? Validated { get; set; } - /// Owner directory page hint (hex UInt64). [JsonPropertyName("OwnerNode")] public string OwnerNode { get; set; } + + /// + /// The identifying hash of the transaction that most recently modified this object. + /// + [JsonPropertyName("PreviousTxnID")] + public string PreviousTxnID { get; set; } + + /// + /// The index of the ledger that contains the transaction that most recently modified this object. + /// + [JsonPropertyName("PreviousTxnLgrSeq")] + public uint? PreviousTxnLgrSeq { get; set; } } public interface IAuthAccount diff --git a/Xrpl/Models/Ledger/LOCredential.cs b/Xrpl/Models/Ledger/LOCredential.cs index c7325f5c..1360741e 100644 --- a/Xrpl/Models/Ledger/LOCredential.cs +++ b/Xrpl/Models/Ledger/LOCredential.cs @@ -101,12 +101,6 @@ public string URI [JsonPropertyName("Flags")] public new uint Flags { get; set; } - /// - /// A hint indicating which page of the owner directory links to this entry. - /// - [JsonPropertyName("OwnerNode")] - public string OwnerNode { get; set; } - /// /// A hint indicating which page of the subject's owner directory links to this entry. /// diff --git a/Xrpl/Models/Ledger/LODirectoryNode.cs b/Xrpl/Models/Ledger/LODirectoryNode.cs index e6e21c43..4999a9fd 100644 --- a/Xrpl/Models/Ledger/LODirectoryNode.cs +++ b/Xrpl/Models/Ledger/LODirectoryNode.cs @@ -102,5 +102,17 @@ public LODirectoryNode() /// MPT order books: MPT issuance id on the TakerGets side. [JsonPropertyName("TakerGetsMPT")] public string TakerGetsMPT { get; set; } + + /// + /// The identifying hash of the transaction that most recently modified this object. + /// + [JsonPropertyName("PreviousTxnID")] + public string PreviousTxnID { get; set; } + + /// + /// The index of the ledger that contains the transaction that most recently modified this object. + /// + [JsonPropertyName("PreviousTxnLgrSeq")] + public uint? PreviousTxnLgrSeq { get; set; } } } diff --git a/Xrpl/Models/Ledger/LOFeeSettings.cs b/Xrpl/Models/Ledger/LOFeeSettings.cs index de6998aa..4b2fc02b 100644 --- a/Xrpl/Models/Ledger/LOFeeSettings.cs +++ b/Xrpl/Models/Ledger/LOFeeSettings.cs @@ -47,5 +47,17 @@ public LOFeeSettings() /// XRPFees: owner reserve increment in drops. [JsonPropertyName("ReserveIncrementDrops")] public string ReserveIncrementDrops { get; set; } + + /// + /// The identifying hash of the transaction that most recently modified this object. + /// + [JsonPropertyName("PreviousTxnID")] + public string PreviousTxnID { get; set; } + + /// + /// The index of the ledger that contains the transaction that most recently modified this object. + /// + [JsonPropertyName("PreviousTxnLgrSeq")] + public uint? PreviousTxnLgrSeq { get; set; } } } diff --git a/Xrpl/Models/Ledger/LOLoan.cs b/Xrpl/Models/Ledger/LOLoan.cs index e4f1bf1d..3b3dafe8 100644 --- a/Xrpl/Models/Ledger/LOLoan.cs +++ b/Xrpl/Models/Ledger/LOLoan.cs @@ -101,12 +101,6 @@ public LOLoan() [JsonPropertyName("PrincipalOutstanding")] public string PrincipalOutstanding { get; init; } - /// - /// The principal amount originally requested (Number type, string representation). - /// - [JsonPropertyName("PrincipalRequested")] - public string PrincipalRequested { get; init; } - /// /// The total amount owed including fees (Number type, string representation). /// diff --git a/Xrpl/Models/Ledger/LONFTokenPage.cs b/Xrpl/Models/Ledger/LONFTokenPage.cs index c6cea0b0..31a555aa 100644 --- a/Xrpl/Models/Ledger/LONFTokenPage.cs +++ b/Xrpl/Models/Ledger/LONFTokenPage.cs @@ -17,11 +17,6 @@ public LONFTokenPage() } [JsonConverter(typeof(NumberOrStringConverter))] public string Flags { get; set; } - /// - /// The locator of the next page, if any. Details about this field and how it should be used are outlined below. - /// - public string NFTokenPage { get; set; } - /// /// The collection of NFToken objects contained in this NFTokenPage object. /// This specification places an upper bound of 32 NFToken objects per page. diff --git a/Xrpl/Models/Ledger/LONegativeUNL.cs b/Xrpl/Models/Ledger/LONegativeUNL.cs index 96441a3b..bc7f8c92 100644 --- a/Xrpl/Models/Ledger/LONegativeUNL.cs +++ b/Xrpl/Models/Ledger/LONegativeUNL.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Text.Json.Serialization; namespace Xrpl.Models.Ledger { @@ -25,6 +26,18 @@ public LONegativeUNL() /// The public key of a trusted validator in the Negative UNL that is scheduled to be re-enabled in the next flag ledger. /// public string ValidatorToReEnable { get; set; } + + /// + /// The identifying hash of the transaction that most recently modified this object. + /// + [JsonPropertyName("PreviousTxnID")] + public string PreviousTxnID { get; set; } + + /// + /// The index of the ledger that contains the transaction that most recently modified this object. + /// + [JsonPropertyName("PreviousTxnLgrSeq")] + public uint? PreviousTxnLgrSeq { get; set; } } public interface IDisabledValidator { diff --git a/Xrpl/Models/Ledger/LOVault.cs b/Xrpl/Models/Ledger/LOVault.cs index ae7b15be..d8594488 100644 --- a/Xrpl/Models/Ledger/LOVault.cs +++ b/Xrpl/Models/Ledger/LOVault.cs @@ -202,12 +202,6 @@ public string DataRaw } } - /// - /// The ID of a permissioned domain associated with the vault. - /// - [JsonPropertyName("DomainID")] - public string DomainID { get; init; } - /// /// The transaction sequence number that created the vault. /// From 3d35665e8d3e86566528032fafc65b891a971d26 Mon Sep 17 00:00:00 2001 From: Aleksandr <44946855+Platonenkov@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:31:37 -0300 Subject: [PATCH 06/14] =?UTF-8?q?fix(client):=20=D0=B1=D1=8D=D0=BA=D0=BE?= =?UTF-8?q?=D1=84=D1=84=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=BE=D0=B2=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=BD=D1=8B=D1=85=20=D1=81=D0=B1=D0=BE=D1=8F=D1=85?= =?UTF-8?q?=20OnConnected=20+=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=87=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20CodeRabbit=20=D0=BA=20=D1=80=D0=B5=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=BD=D0=BE=D0=BC=D1=83=20PR=20#76=20(#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(client): бэкофф при повторных сбоях OnConnected + замечания CodeRabbit к PR #76 * Бэкофф не рос при повторяющихся сбоях OnConnected-хендлера. Путь OnConnectHandlerFailedAsync сносит цикл переподключения и запускает заново на каждом сбое; StopReconnectLoop обнуляет _reconnectAttempts, свежая последовательность обнуляет его ещё раз, а CalcBackoff считает задержку только по этому счётчику. При StopAfterMaxAttempts = false ветки сдачи нет вовсе, и клиент бесконечно повторял connect -> сбой хендлера -> teardown с постоянной ReconnectBaseDelay — устойчивая нагрузка ровно на ту ноду, которая ещё не умеет обслуживать запросы. StartReconnectLoop получил параметр начального значения счётчика, путь сбоя хендлера засевает его своим числом последовательных сбоев. TestRepeatedOnConnectedFailuresBackOff это пинит: с откаченным фиксом тест показывает ~100 переподключений за 20 с с ровным интервалом ~200 мс * _reconnectCts объявлен volatile: цикл сравнивает его по ссылке, решая, владеет ли он ещё состоянием переподключения, а пишут его три метода из других потоков. Устаревшее чтение дало бы retired-циклу лишнюю итерацию либо увело бы владеющий цикл раньше времени. Соседние кросс-потоковые поля уже volatile * Гайд по кредитованию (обе языковые версии): таблица Loan Fields перечисляла четыре имени, которых у ledger-объекта нет — Account (заёмщик лежит в Borrower), а также Counterparty, PrincipalRequested и PaymentTotal, которые являются полями транзакции LoanSet. После удаления PrincipalRequested из LOLoan гайд обещал бы несуществующее свойство * TestUtils.GetFreePort больше не выдаёт один порт дважды в пределах процесса: ОС вправе вернуть только что освобождённый порт, классы тестов идут параллельно, и второй mock падал бы при бинде в фоновом потоке — это выглядело как таймаут, а не как конфликт. TestUChangeServerFailure дополнительно проверяет порт прямо перед стартом второго mock, чтобы остаточная внешняя гонка падала внятно * RippledLedgerFlags.Parse падает на повторно объявленном объекте, как это уже делает RippledLedgerEntryFormats.Parse. Проверено мутацией (дублирование блока Offer), фикстура после проверки восстановлена * Фикстуры в тестовом .csproj подключены через None Update вместо None Include — дефолтный glob SDK их уже включает Версия не бампится: 10.11.0.0 ещё не выпущен, правки дописаны в его раздел. Проверено: сборка решения без ошибок, юнит-тесты 880/880; обе вендоренные фикстуры сверены с пинами через curl | diff. * fix(tests): ловить дубликат ledger-объекта до пропуска бесфлаговых Замечание CodeRabbit к PR #77: проверка дубликата стояла ПОСЛЕ `if (flags.Count == 0) continue`, поэтому имя, объявленное дважды, проскакивало, если одно из объявлений разбиралось без флагов. Имена теперь отслеживаются отдельным HashSet до этой ветки, а `objects` по-прежнему хранит только флагованные записи. Проверено мутацией именно этого сценария: вставка второго `LEDGER_OBJECT(Offer, )` с пустым телом теперь даёт "Offer: declared twice in LedgerFormats.h", а до правки проходила молча. Фикстура после проверки восстановлена и сверена с пином через curl | diff. Побочно всплыло, что PreserveNewest не обновляет копию фикстуры в bin, когда исходник возвращают из git (у восстановленного файла время правки старше копии): после мутационных проверок каталог Fixtures в bin нужно удалять, иначе тесты идут против подделанного файла. На этом и попались два прогона. Проверено: юнит-тесты 880/880. --- CHANGES.md | 5 ++ DocFx/LendingProtocol-Guide.md | 10 +-- DocFx/LendingProtocol-Guide.ru.md | 10 +-- .../Client/TestUChangeServerFailure.cs | 8 +++ .../Client/TestUOnConnectedHandlerFailure.cs | 62 ++++++++++++++++++ Tests/Xrpl.Tests/Models/RippledLedgerFlags.cs | 16 ++++- Tests/Xrpl.Tests/TestUtils.cs | 64 +++++++++++++++++-- Tests/Xrpl.Tests/Xrpl.Tests.csproj | 9 ++- Xrpl/Client/connection.cs | 27 ++++++-- 9 files changed, 187 insertions(+), 24 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 80ddaeee..e4603159 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,11 @@ ## 10.11.0.0 08/04/2026 +* **Repeated `OnConnected` handler failures now back off** — the give-up branch added earlier is bounded by `MaxReconnectAttempts`, but only when `StopAfterMaxAttempts` is set. With it turned off there is no give-up at all, and the delay between retries never grew: this path tears the reconnect loop down and starts it again on every failure, `StopReconnectLoop` zeroes `_reconnectAttempts`, a fresh sequence zeroes it again, and `CalcBackoff` derives the delay from that counter alone. The client therefore repeated connect → handler failure → teardown at a constant `ReconnectBaseDelay` forever — a sustained connection load on exactly the node that cannot serve requests yet. `StartReconnectLoop` now takes the value to seed the counter with, and the handler-failure path seeds it from its own consecutive-failure count so the sequence keeps growing across failures. `TestRepeatedOnConnectedFailuresBackOff` pins it; reverting the fix makes that test show ~100 reconnects in 20s at a flat ~200ms interval +* **`_reconnectCts` is `volatile`** — the reconnect loop compares it by reference to decide whether it still owns the reconnect state, while `StopReconnectLoop`, `StartReconnectLoop` and `RetireCurrentSessionAndReconnectAsync` write it from other threads. A stale read could let a retired loop run one more iteration or make the owning loop stand down early. The other cross-thread fields in that class were already `volatile` +* **Lending guide corrected** — the `Loan Fields` table in `LendingProtocol-Guide` (both languages) listed four names the ledger object does not have: `Account` (the borrower is in `Borrower`), plus `Counterparty`, `PrincipalRequested` and `PaymentTotal`, which are fields of the **`LoanSet` transaction**. After `PrincipalRequested` was removed from `LOLoan` in this release the guide would have promised a property that no longer exists. Fixed, with a note pointing the three transaction fields at `LoanSet` +* **Test-side fixes** — `TestUtils.GetFreePort` never handed out a port twice within the process (the OS is free to return a just-released port, and test classes run in parallel, so two callers could get the same one and the second mock would fail to bind on its background thread, surfacing as a timeout rather than an error); `TestUChangeServerFailure` checks the port is still free right before starting the second mock, so the remaining external race fails fast with a clear message; `RippledLedgerFlags.Parse` throws on a ledger object declared twice, matching `RippledLedgerEntryFormats.Parse`; the fixture entries in the test `.csproj` use `None Update` instead of `None Include`, since the SDK's default glob already includes them + * **`TestULedgerEntryFieldsConformance` — the third conformance surface**, completing the set next to `TestUTxFormatConformance` (transaction fields) and `TestULedgerFlagsConformance` (ledger flags). `ledger_entries.macro` is the only place the protocol states which fields belong to which ledger object — `definitions.json` carries field codes and object types but not the per-object lists — and nothing checked it. A missing field produces no symptom: reading the object still succeeds and the value is silently dropped, which is how `LOAccountRoot` went without `WalletLocator`/`WalletSize` until a manual pass, and how `sfLEVersion` had to arrive through a protocol-watch notification instead of a red test: * `Tests/Xrpl.Tests/Fixtures/ledger_entries.macro`, vendored byte-identical and pinned by sha in the `.ref`. Pinned to a **develop** commit rather than a tag, unlike `LedgerFormats.h`: the models track develop for fields, and `sfLEVersion` exists only after 07/30/2026, so a tag would report it as a field the SDK invented * both directions are diffed — a field rippled declares and the model lacks, and a property the model exposes that is not a field of that object — and every ledger object must be registered against a model, so a newly added one fails the build instead of being skipped diff --git a/DocFx/LendingProtocol-Guide.md b/DocFx/LendingProtocol-Guide.md index 9808c087..f3db8a8f 100644 --- a/DocFx/LendingProtocol-Guide.md +++ b/DocFx/LendingProtocol-Guide.md @@ -365,18 +365,20 @@ The lending protocol creates the following ledger objects: | Field | Type | Description | |-------|------|-------------| -| `Account` | AccountID | Borrower account | -| `Counterparty` | AccountID | Broker account | +| `Borrower` | AccountID | Borrower account | | `LoanBrokerID` | Hash256 | Reference to loan broker | -| `PrincipalRequested` | Number | Original loan amount | +| `LoanSequence` | UInt32 | Sequence number within the broker | | `PrincipalOutstanding` | Number | Remaining principal | | `TotalValueOutstanding` | Number | Total amount owed | +| `PeriodicPayment` | Number | Amount due per interval | | `InterestRate` | UInt32 | Annual interest rate | | `PaymentInterval` | UInt32 | Seconds between payments | -| `PaymentTotal` | UInt32 | Total number of payments | +| `GracePeriod` | UInt32 | Seconds before late fees apply | | `PaymentRemaining` | UInt32 | Remaining payments | | `StartDate` | UInt32 | Loan start (Ripple epoch) | +> `Counterparty`, `PrincipalRequested` and `PaymentTotal` are fields of the **`LoanSet` transaction**, not of the `Loan` object. rippled records the requested principal as `PrincipalOutstanding`, so the ledger entry carries no `PrincipalRequested` — and neither does `LOLoan`. + ### Querying Loan State Use `account_objects` to retrieve loans owned by an account: diff --git a/DocFx/LendingProtocol-Guide.ru.md b/DocFx/LendingProtocol-Guide.ru.md index 3d886c06..d808c471 100644 --- a/DocFx/LendingProtocol-Guide.ru.md +++ b/DocFx/LendingProtocol-Guide.ru.md @@ -365,18 +365,20 @@ await client.SubmitRequest(fullySigned.TxBlob); | Поле | Тип | Описание | |------|-----|----------| -| `Account` | AccountID | Аккаунт заёмщика | -| `Counterparty` | AccountID | Аккаунт брокера | +| `Borrower` | AccountID | Аккаунт заёмщика | | `LoanBrokerID` | Hash256 | Ссылка на кредитного брокера | -| `PrincipalRequested` | Number | Исходная сумма кредита | +| `LoanSequence` | UInt32 | Порядковый номер в рамках брокера | | `PrincipalOutstanding` | Number | Остаток основной суммы | | `TotalValueOutstanding` | Number | Общая задолженность | +| `PeriodicPayment` | Number | Сумма платежа за интервал | | `InterestRate` | UInt32 | Годовая процентная ставка | | `PaymentInterval` | UInt32 | Интервал между платежами (секунды) | -| `PaymentTotal` | UInt32 | Общее количество платежей | +| `GracePeriod` | UInt32 | Отсрочка до начисления пеней (секунды) | | `PaymentRemaining` | UInt32 | Оставшиеся платежи | | `StartDate` | UInt32 | Начало кредита (Ripple epoch) | +> `Counterparty`, `PrincipalRequested` и `PaymentTotal` — поля **транзакции `LoanSet`**, а не объекта `Loan`. Запрошенную сумму rippled записывает в `PrincipalOutstanding`, поэтому у ledger-объекта поля `PrincipalRequested` нет — как нет его и у `LOLoan`. + ### Запрос состояния кредита Используйте `account_objects` для получения кредитов аккаунта: diff --git a/Tests/Xrpl.Tests/Client/TestUChangeServerFailure.cs b/Tests/Xrpl.Tests/Client/TestUChangeServerFailure.cs index 1ab724b6..916ea34c 100644 --- a/Tests/Xrpl.Tests/Client/TestUChangeServerFailure.cs +++ b/Tests/Xrpl.Tests/Client/TestUChangeServerFailure.cs @@ -115,6 +115,11 @@ public async Task TestChangeServerToUnreachableServerRecoversWhenItComesUp() "A ChangeServer target that is down is a connection failure, not a permanent disconnect."); // The server appears afterwards - exactly the "start the node later" case. + // The mock binds on a background thread, so a port taken in the meantime would + // surface as a 30s timeout below rather than as a bind error; check first. + Assert.IsTrue( + TestUtils.IsPortStillFree(secondPort), + $"Port {secondPort} was taken by another process while the test held it — rerun."); _secondRippled = StartMock(secondPort); DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); @@ -166,6 +171,9 @@ public async Task TestChangeServerAfterUserDisconnectStillReconnects() // Expected - the target is not up yet. } + Assert.IsTrue( + TestUtils.IsPortStillFree(secondPort), + $"Port {secondPort} was taken by another process while the test held it — rerun."); _secondRippled = StartMock(secondPort); DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); diff --git a/Tests/Xrpl.Tests/Client/TestUOnConnectedHandlerFailure.cs b/Tests/Xrpl.Tests/Client/TestUOnConnectedHandlerFailure.cs index 2869b432..c8c372e5 100644 --- a/Tests/Xrpl.Tests/Client/TestUOnConnectedHandlerFailure.cs +++ b/Tests/Xrpl.Tests/Client/TestUOnConnectedHandlerFailure.cs @@ -247,5 +247,67 @@ public async Task TestOnConnectedFailureIsReportedThroughOnError() Assert.AreSame(completed, reported.Task, "OnConnected failure was never reported through OnError."); StringAssert.Contains(reported.Task.Result, "subscribe failed after connect"); } + + /// + /// With StopAfterMaxAttempts = false there is no give-up branch, so a permanently + /// failing handler reconnects forever. The delay between attempts must still grow: this + /// path tears the reconnect loop down and starts it again on every failure, and the loop + /// derives its delay from the attempt counter alone — seeded from zero it would hammer a + /// node that accepts TCP but cannot serve requests at a constant ReconnectBaseDelay. + /// + [TestMethod] + public async Task TestRepeatedOnConnectedFailuresBackOff() + { + List attempts = new List(); + TaskCompletionSource enough = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + _client = CreateClient(maxReconnectAttempts: 50, stopAfterMaxAttempts: false); + _client.connection.OnConnected += () => + { + lock (attempts) + { + attempts.Add(DateTime.UtcNow); + if (attempts.Count >= 4) + { + enough.TrySetResult(true); + } + } + + throw new InvalidOperationException("subscribe failed after connect"); + }; + + try + { + await _client.Connect(); + } + catch (Exception) + { + // Expected: the first handler invocation throws. + } + + Task completed = await Task.WhenAny(enough.Task, Task.Delay(TimeSpan.FromSeconds(30))); + Assert.AreSame(completed, enough.Task, "The client stopped retrying a failing handler."); + + List gaps = new List(); + lock (attempts) + { + for (int i = 1; i < attempts.Count; i++) + { + gaps.Add(attempts[i] - attempts[i - 1]); + } + } + + // ReconnectBaseDelay is 100ms and CalcBackoff doubles per attempt with 25% jitter, + // so the third gap is ~4x the first even at the extremes of the jitter range. + // Comparing first vs last rather than each consecutive pair keeps the assertion + // robust: what regressed before was a flat sequence, not the exact multiplier. + Assert.IsTrue( + gaps.Count >= 3, + $"Expected at least 3 gaps between handler invocations, got {gaps.Count}."); + Assert.IsTrue( + gaps[gaps.Count - 1] > gaps[0], + $"Backoff did not grow across consecutive handler failures: {string.Join(", ", gaps)}"); + } } } diff --git a/Tests/Xrpl.Tests/Models/RippledLedgerFlags.cs b/Tests/Xrpl.Tests/Models/RippledLedgerFlags.cs index 46a16d2a..d7614637 100644 --- a/Tests/Xrpl.Tests/Models/RippledLedgerFlags.cs +++ b/Tests/Xrpl.Tests/Models/RippledLedgerFlags.cs @@ -76,9 +76,23 @@ internal static Dictionary> Parse() Dictionary> objects = new(); int flagCount = 0; + // Tracked separately from `objects`, which only holds flagged entries: a name declared + // twice must be caught even when one of the two declarations parses to no flags at all, + // otherwise the flagless-skip below would let the duplicate through unnoticed. + HashSet seenNames = new(StringComparer.Ordinal); + foreach (Match block in ObjectBlock.Matches(header)) { string name = block.Groups["name"].Value; + + // Same rule as RippledLedgerEntryFormats.Parse, so the two parsers stay consistent + if (!seenNames.Add(name)) + { + throw new InvalidOperationException( + $"{name}: declared twice in LedgerFormats.h — the parser would drop one " + + "definition, update it before trusting this test"); + } + Dictionary flags = new(); foreach (Match flag in FlagEntry.Matches(block.Groups["body"].Value)) @@ -92,7 +106,7 @@ internal static Dictionary> Parse() if (flags.Count == 0) continue; - objects[name] = flags; + objects.Add(name, flags); flagCount += flags.Count; } diff --git a/Tests/Xrpl.Tests/TestUtils.cs b/Tests/Xrpl.Tests/TestUtils.cs index 1420cb87..746e7d2c 100644 --- a/Tests/Xrpl.Tests/TestUtils.cs +++ b/Tests/Xrpl.Tests/TestUtils.cs @@ -1,6 +1,8 @@ - + // https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/test/testUtils.ts +using System; +using System.Collections.Concurrent; using System.Net; using System.Net.Sockets; @@ -8,14 +10,62 @@ namespace Xrpl.Tests { public class TestUtils { + /// + /// Ports this process has already handed out. The OS is free to return a just-released + /// port to the next caller, and test classes run in parallel (see test.runsettings), so + /// two callers could otherwise receive the same port and the second server would fail to + /// bind — silently, because the mock listens on a background thread, leaving the test to + /// time out instead of reporting a conflict. + /// + private static readonly ConcurrentDictionary ClaimedPorts = new(); + + /// + /// A loopback port free at the moment of the call and not handed out before. + /// + /// + /// The listener is stopped before returning, so the port is closed when the caller gets + /// it — several tests need exactly that (connect to a server that is not up yet, start it + /// later). The gap that leaves cannot be closed while callers need a closed port; what + /// this does remove is the collision between concurrent callers inside this process, + /// which is the reachable half of the race. + /// static public int GetFreePort() { - TcpListener l = new TcpListener(IPAddress.Loopback, 0); - l.Start(); - int port = ((IPEndPoint)l.LocalEndpoint).Port; - l.Stop(); - return port; + for (int attempt = 0; attempt < 50; attempt++) + { + TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + + if (ClaimedPorts.TryAdd(port, 0)) + { + return port; + } + } + + throw new InvalidOperationException( + "GetFreePort: could not obtain an unclaimed loopback port after 50 attempts"); + } + + /// + /// Whether can still be bound on loopback right now. Tests that + /// hold a port across an await use this to fail fast with a clear reason instead of + /// waiting out a connection timeout when something else took it. + /// + static public bool IsPortStillFree(int port) + { + try + { + TcpListener listener = new TcpListener(IPAddress.Loopback, port); + listener.Start(); + listener.Stop(); + return true; + } + catch (SocketException) + { + return false; + } } } } - diff --git a/Tests/Xrpl.Tests/Xrpl.Tests.csproj b/Tests/Xrpl.Tests/Xrpl.Tests.csproj index 72fd5fa9..95f801ec 100644 --- a/Tests/Xrpl.Tests/Xrpl.Tests.csproj +++ b/Tests/Xrpl.Tests/Xrpl.Tests.csproj @@ -30,13 +30,16 @@ PreserveNewest - + + PreserveNewest - + PreserveNewest - + PreserveNewest diff --git a/Xrpl/Client/connection.cs b/Xrpl/Client/connection.cs index 8860add2..2362bc6d 100644 --- a/Xrpl/Client/connection.cs +++ b/Xrpl/Client/connection.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -287,7 +287,11 @@ private static WebSocketClient CreateWebSocket(string url, ConnectionOptions con private static readonly Random _random = new(); - private CancellationTokenSource _reconnectCts; + // Volatile: StopReconnectLoop, StartReconnectLoop and RetireCurrentSessionAndReconnectAsync + // write this from other threads, and the loop compares it by reference to decide whether it + // still owns the reconnect state. A stale read would let a retired loop run one more + // iteration, or make the owning loop stand down. Matches the other cross-thread fields here. + private volatile CancellationTokenSource _reconnectCts; private Task _reconnectLoop; @@ -1787,9 +1791,15 @@ await errorHandler // and the one in OnceClose would then race with the loop's exit, and losing the race leaves nobody // reconnecting - the very wedge this path exists to prevent. Cancel whatever is there, start fresh; // the later OnceClose sees a live loop and correctly stands down. + // Seed the attempt counter with the consecutive-failure count. StopReconnectLoop zeroes + // _reconnectAttempts and a fresh sequence would zero it again, and CalcBackoff derives the + // delay from that counter alone — so without the seed every handler failure would restart + // the backoff at ReconnectBaseDelay. With StopAfterMaxAttempts = false (no give-up branch) + // that means connect -> handler failure -> teardown forever at a constant 2s, a sustained + // connection load on a node that accepts TCP but cannot serve requests yet. StopReconnectLoop(); _reconnectLoop = null; - StartReconnectLoop(); + StartReconnectLoop(initialAttempts: failures); } private async Task OnceClose(int? code, string? description, WebSocketClient closingSocket, long sessionId) @@ -1955,7 +1965,14 @@ private void ClearReconnectState() _reconnectMode = ReconnectMode.None; } - private void StartReconnectLoop() + /// + /// Value to seed _reconnectAttempts with when a fresh reconnect sequence starts. + /// Defaults to 0 — a genuine new sequence begins at the base delay. The OnConnected-handler + /// path passes its own consecutive-failure count instead: that path tears the loop down and + /// starts it again on every failure, so with a 0 seed the backoff would restart at + /// ReconnectBaseDelay each time and never grow. + /// + private void StartReconnectLoop(int initialAttempts = 0) { // Set reconnect mode to LoopReconnect (upgrades from FastReconnect or sets from None) _reconnectMode = ReconnectMode.LoopReconnect; @@ -1983,7 +2000,7 @@ private void StartReconnectLoop() existingCts?.Cancel(); existingCts?.Dispose(); _reconnectCts = new CancellationTokenSource(); - _reconnectAttempts = 0; + _reconnectAttempts = initialAttempts; } // else: Reuse existing valid CTS (pre-created for fast reconnect) // Don't reset _reconnectAttempts - this is continuation of existing reconnect sequence From f3d5d61c09c1e06a46fa4eb274a168d6125749d8 Mon Sep 17 00:00:00 2001 From: Aleksandr <44946855+Platonenkov@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:44:56 -0300 Subject: [PATCH 07/14] =?UTF-8?q?fix(client):=20=D1=81=D0=B8=D0=BD=D1=85?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B6=D0=B8=D0=B7=D0=BD=D0=B5=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=86=D0=B8=D0=BA=D0=BB=20=D1=81=D0=B5=D1=81=D1=81?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BF=D0=BE=D0=B4=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20(#78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(client): синхронизировать жизненный цикл сессии переподключения volatile делает атомарным каждое отдельное обращение, но не их последовательность. StopReconnectLoop читал _reconnectCts трижды подряд — Cancel, Dispose, = null — и старт, попавший между этими обращениями, получал свой свежий источник уничтоженным уходящим стопом. Цикл оставался с мёртвым источником, отступал по проверке владения, а замену никто не запускал: клиент вставал навсегда — ровно тот исход, против которого писалась вся эта область. Введён _reconnectStateLock, накрывающий каждое чтение-с-изменением тройки _reconnectCts / _reconnectLoop / _reconnectAttempts во всех четырёх точках записи: RetireCurrentSessionAndReconnectAsync (замена сессии и её же успешное завершение), StopReconnectLoop, StartReconnectLoop и хвост ReconnectLoopAsync. Решение «цикл уже идёт / источник переиспользуем / ставим новый / отдаём его циклу» стало одной транзакцией — иначе гонка давала два живых цикла либо цикл с уже уничтоженным источником. Под локом не выполняется ничего, что может позвать потребительский код: отмена и освобождение отставленного источника вынесены за лок, а ReconnectLoopAsync начинается с await Task.Yield(), поэтому старт цикла под локом только планирует его и возвращается — уведомление не выполняется на стеке вызывающего. Без этого Disconnect из обработчика мог бы войти в тот же лок и заклинить. volatile у поля сохранён: проверки владения в цикле читают его вне лока, а одиночное чтение ссылки атомарно и ничего не изменяет. TestUReconnectSessionRaces — и сразу оговорка: эти тесты НЕ воспроизводят исходную гонку. Окно шириной в несколько инструкций из публичного API, где между вызовами лежат целые await'ы, не достаётся: с убранным локом тесты по-прежнему проходят (проверено мутацией, три прогона). Ценность у них обратная — сам лок создаёт риск взаимной блокировки, и они гоняют конкурентные ChangeServer и Disconnect, требуя, чтобы клиент затем дошёл до живого сервера. Дедлок или потерянная сессия проявятся здесь, а не в бою. Проверено: юнит-тесты 882/882; TestIConnectionStates 7/7 на CI-стенде; сборка решения без ошибок. * fix(client): не читать Token после yield и перезапускать цикл одной транзакцией Селф-ревью предыдущего коммита нашло два дефекта, один из которых внесён им же. * ObjectDisposedException в ReconnectLoopAsync. Добавленный await Task.Yield() стоял ПЕРЕД чтением ownCts.Token, а Cancel/Dispose отставленного источника вынесены за лок — значит за время ожидания продолжения другой поток успевал уничтожить именно этот источник, и обращение к .Token бросало ObjectDisposedException вне всякого try. Таск падал, не сделав ни одной попытки переподключения, а исключение терялось как unobserved. До прошлого коммита Token читался синхронно, так что окна не было вовсе — оно возникло ровно из-за yield. Теперь Token читается ДО yield, пока вызывающий ещё держит лок и источник заведомо жив; Task.Delay(delay, ct) дополнительно ловит ObjectDisposedException и трактует его как отставку, а не как сбой * RestartReconnectLoop. Последовательность StopReconnectLoop(); _reconnectLoop = null; StartReconnectLoop(seed) брала лок дважды, а запись между вызовами шла без лока. Конкурентный старт из OnceClose или OnConnectionFailed успевал поставить свой цикл, после чего засеянный старт видел живой цикл и молча выходил, не применив seed — тихий откат роста бэкоффа, ради которого seed и вводился. Retire, установка нового источника, счётчика и запуск цикла делаются одной транзакцией под локом * XML-док у _reconnectStateLock больше не утверждает, что лок покрывает «every read-modify-write»: перечислены места, которые он реально охватывает, и явно названы оставшиеся вне его (per-iteration инкремент, сбросы в ChangeServer и OnceClose) Проверено мутацией: если в RestartReconnectLoop не применять seed, TestRepeatedOnConnectedFailuresBackOff падает и показывает ~90 переподключений подряд с ровным интервалом ~200 мс. Юнит-тесты 882/882. * fix(demo): показывать фактический сервер после неудачной смены Blazor-демо обновляло CurrentServerUrl только при успешном ChangeServer: присваивание стояло в try сразу после await. ChangeServer переключает цель клиента ДО попытки подключения, поэтому при переходе на недоступный сервер он бросает по таймауту получения соединения — а клиент к этому моменту уже переподключается к НОВОМУ адресу. Метка же продолжала показывать прежний сервер. Дальше ломалась и кнопка: гвард "Already connected to this server" сравнивал выбранный адрес с этим устаревшим значением и отказывался переключаться обратно на сервер, к которому клиент на самом деле подключён не был. Присваивание перенесено в finally, то есть выполняется независимо от исхода. SDK тут ни при чём: connection.GetUrl()/client.Url() возвращает новый адрес сразу после ChangeServer. Проверено вручную в демо против локальной ноды 3.2.1: переключение на ws://127.0.0.1:19997 (порт мёртв) — метка показывает 19997, а не прежний mainnet; последующее переключение на ws://localhost:6006 проходит и подключается за 0.3 с, тогда как раньше отбивалось сообщением "Already connected to this server". * fix(client): снимать ссылку на цикл внутри стоп-транзакции Замечание CodeRabbit к PR #78 (Major). StopReconnectLoop обнулял _reconnectCts, но оставлял _reconnectLoop. Отставленный цикл выходит асинхронно — он узнаёт о потере владения только на следующей проверке, — поэтому оставшаяся ссылка даёт такую последовательность: StartReconnectLoop видит !IsCompleted и возвращается, ничего не запустив, а отставленный цикл тут же отступает по проверке владения. Не остаётся никого, кто бы переподключался. Достижимо, когда Connect или ChangeServer гасит живой цикл, а новое подключение падает. Ссылка снимается в той же транзакции под локом. Нитпики оттуда же: * TestUReconnectSessionRaces: финальный Connect обёрнут, чтобы падал ассерт с внятным текстом, а не сырое исключение от проигравшего гонку переключения * StartMock вызывает mock.Start() напрямую: он биндит, слушает и уходит в BeginAccept, не блокируя, так что возврат из него уже означает готовность порта. Обёртка в поток лишь открывала окно, где тест успевал подключиться раньше mock'а. В соседнем TestUChangeServerFailure обёртка осталась — отдельная уборка, этот PR его не трогает TestFailedConnectDuringReconnectLeavesLoopRunning закрывает функциональный путь (Connect во время реконнекта, сервер появляется позже). Гонку он НЕ пинит: нужно, чтобы отставленная задача была ещё жива в момент проверки IsCompleted, а к приходу неудачного Connect она обычно уже вышла — с откаченным фиксом тест проходит. Это записано в его XML-доке, чтобы его не приняли за регрессионный. Проверено: юнит-тесты 883/883. --- .../Blazor-WebAssembly/Pages/Index.razor | 9 +- .../Client/TestUReconnectSessionRaces.cs | 295 ++++++++++++++++++ Xrpl/Client/connection.cs | 243 +++++++++++---- 3 files changed, 490 insertions(+), 57 deletions(-) create mode 100644 Tests/Xrpl.Tests/Client/TestUReconnectSessionRaces.cs diff --git a/Tests/TestsClients/Blazor-WebAssembly/Pages/Index.razor b/Tests/TestsClients/Blazor-WebAssembly/Pages/Index.razor index 81c4a731..e2eee217 100644 --- a/Tests/TestsClients/Blazor-WebAssembly/Pages/Index.razor +++ b/Tests/TestsClients/Blazor-WebAssembly/Pages/Index.razor @@ -719,8 +719,6 @@ AddStatusMessage($"Changing server to: {newServerUrl}...", MessageType.Info); await client.ChangeServer(newServerUrl); - - CurrentServerUrl = client.Url(); } catch (Exception ex) { @@ -730,6 +728,13 @@ } finally { + // Read the address back whether or not the connection succeeded. ChangeServer switches + // the client's target before it starts connecting, so a switch to a server that is down + // throws (the acquisition timeout) while the client is already reconnecting to the NEW + // address. Updating this only on success left the label showing the previous server, and + // the "Already connected to this server" guard above then compared against a stale value + // and refused a legitimate switch back. + CurrentServerUrl = client.Url(); IsChangingServer = false; StateHasChanged(); } diff --git a/Tests/Xrpl.Tests/Client/TestUReconnectSessionRaces.cs b/Tests/Xrpl.Tests/Client/TestUReconnectSessionRaces.cs new file mode 100644 index 00000000..7d434a45 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestUReconnectSessionRaces.cs @@ -0,0 +1,295 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using Xrpl.Client; + +namespace Xrpl.Tests +{ + /// + /// Concurrency smoke tests for the reconnect session — the _reconnectCts / + /// _reconnectLoop / _reconnectAttempts triple, which is now updated under a + /// shared lock. + /// + /// + /// + /// These do not reproduce the race the lock fixes. That window is a few instructions + /// wide — a start landing between the stop path's cancel, dispose and null — and driving it + /// from public API calls, which are separated by whole awaits, does not hit it: with the lock + /// removed again these tests still pass. Claiming them as regression coverage would be false. + /// + /// + /// What they do earn their place for is the other direction. Introducing a lock around the + /// session creates a deadlock risk of its own: the loop is now started while the lock is held, + /// and anything that called back into consumer code from there could re-enter a path that takes + /// the same lock. These tests hammer ChangeServer and Disconnect concurrently and require the + /// client to still reach a live server afterwards, so a deadlock or a lost session shows up as + /// a hang or a failure here rather than in production. + /// + /// + [TestClass] + public class TestUReconnectSessionRaces + { + private CreateMockRippled _mockedRippled; + private CreateMockRippled _secondRippled; + private XrplClient _client; + private int _port; + + private static Dictionary ServerInfoResponse() => new Dictionary + { + { "type", "response" }, + { "status", "success" }, + { "result", new Dictionary + { + { "info", new Dictionary + { + { "build_version", "test-mock" }, + { "complete_ledgers", "1-1" }, + { "server_state", "full" }, + } + }, + } + }, + }; + + private static CreateMockRippled StartMock(int port) + { + CreateMockRippled mock = new CreateMockRippled(port) { suppressOutput = true }; + mock.AddResponse("server_info", ServerInfoResponse()); + + // Called directly rather than on a background thread: Start() binds, listens and hands + // off to BeginAccept without blocking, so returning from it means the port is already + // accepting. Handing it to a thread only opened a window where a test could connect + // before the mock was up. + mock.Start(); + return mock; + } + + [TestInitialize] + public void MyTestInitialize() + { + _port = TestUtils.GetFreePort(); + _mockedRippled = StartMock(_port); + } + + [TestCleanup] + public async Task MyTestCleanup() + { + if (_client != null) + { + try + { + await _client.Disconnect(); + } + catch (Exception) + { + // The client may already be down; cleanup must not mask the test result. + } + + _client = null; + } + + _mockedRippled?.Stop(); + _secondRippled?.Stop(); + } + + private XrplClient CreateClient(string url) => + new XrplClient(url, new XrplClient.ClientOptions + { + RequestPolicy = RequestFailurePolicy.ImmediateFail, + ReconnectBaseDelay = TimeSpan.FromMilliseconds(50), + ReconnectMaxDelay = TimeSpan.FromMilliseconds(400), + MaxReconnectAttempts = 100, + StopAfterMaxAttempts = false, + ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(20), + ConnectionAttemptTimeout = TimeSpan.FromSeconds(5), + UseCustomPing = false, + }); + + /// + /// Concurrent ChangeServer calls tear down and install reconnect sessions from several + /// threads at once. Whatever interleaving wins, the client must end up able to connect to the + /// live server — not stranded with a disposed or orphaned session. + /// + [TestMethod] + public async Task TestConcurrentChangeServerKeepsClientRecoverable() + { + int deadPortA = TestUtils.GetFreePort(); + int deadPortB = TestUtils.GetFreePort(); + + _client = CreateClient($"ws://127.0.0.1:{_port}"); + await _client.Connect(); + Assert.IsTrue(_client.connection.IsConnected(), "Precondition: connected to the live mock."); + + // Writers of the reconnect session running at once: two pointed at ports where nothing + // listens (each starts a reconnect sequence), one pointed back at the live server, plus a + // Disconnect taking the session down underneath them. + for (int round = 0; round < 5; round++) + { + Task[] racers = + { + SwitchTo($"ws://127.0.0.1:{deadPortA}"), + SwitchTo($"ws://127.0.0.1:{deadPortB}"), + SwitchTo($"ws://127.0.0.1:{_port}"), + Task.Run(async () => + { + // Disconnect takes the same session down while the switches install new + // ones — the stop-vs-start interleaving the lock has to make safe. + try + { + await _client.Disconnect(); + } + catch (Exception) + { + } + }), + }; + + await Task.WhenAll(racers); + } + + // Whoever won, point the client at the live server and require it to get there. + await SwitchTo($"ws://127.0.0.1:{_port}"); + try { await _client.Connect(); } catch (Exception) { } + + DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); + while (!_client.connection.IsConnected() && DateTime.UtcNow < deadline) + { + await Task.Delay(TimeSpan.FromMilliseconds(100)); + } + + Assert.IsTrue( + _client.connection.IsConnected(), + "After concurrent ChangeServer calls the client could not reach a server that is up — " + + "the reconnect session was left disposed or orphaned."); + } + + /// + /// Disconnect racing a reconnect sequence must leave the client cleanly stopped and + /// still able to reconnect afterwards — a stop that tore down someone else's session would + /// either strand a live loop or leave a stale one running. + /// + [TestMethod] + public async Task TestDisconnectRacingReconnectLeavesClientReconnectable() + { + int deadPort = TestUtils.GetFreePort(); + + _client = CreateClient($"ws://127.0.0.1:{_port}"); + await _client.Connect(); + + for (int round = 0; round < 5; round++) + { + // Start a reconnect sequence against a dead port and disconnect while it runs. + Task switching = SwitchTo($"ws://127.0.0.1:{deadPort}"); + Task disconnecting = Task.Run(async () => + { + await Task.Delay(TimeSpan.FromMilliseconds(20)); + await _client.Disconnect(); + }); + + await Task.WhenAll(switching, disconnecting); + } + + // The client must still be usable: point it back at the live server and connect. + // Both calls are tolerated so the assertion below reports the failure, rather than the + // test dying on a raw exception from a switch that lost a race. + await SwitchTo($"ws://127.0.0.1:{_port}"); + try + { + await _client.Connect(); + } + catch (Exception) + { + } + + DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30); + while (!_client.connection.IsConnected() && DateTime.UtcNow < deadline) + { + await Task.Delay(TimeSpan.FromMilliseconds(100)); + } + + Assert.IsTrue( + _client.connection.IsConnected(), + "Disconnect racing a reconnect sequence left the client unable to connect again."); + } + + /// + /// A failed Connect issued while a reconnect loop is already running must leave a + /// live loop behind, so the client still comes back on its own once the server returns. + /// + /// + /// Covers the functional path end to end. It does not pin the narrow race that made + /// StopReconnectLoop drop the loop reference: that needs the retired task to still be + /// running when the restart checks IsCompleted, and by the time a failed Connect gets + /// there the task has normally already exited, so the loop is restarted either way — with + /// the fix reverted this test still passes. Kept because the path itself (Connect while + /// reconnecting, server appears later) is worth guarding. + /// + [TestMethod] + public async Task TestFailedConnectDuringReconnectLeavesLoopRunning() + { + int laterPort = TestUtils.GetFreePort(); + + // Short acquisition timeout: the Connect below is expected to fail, and waiting out the + // class default would add 20s of nothing to the run. + _client = new XrplClient($"ws://127.0.0.1:{_port}", new XrplClient.ClientOptions + { + RequestPolicy = RequestFailurePolicy.ImmediateFail, + ReconnectBaseDelay = TimeSpan.FromMilliseconds(50), + ReconnectMaxDelay = TimeSpan.FromMilliseconds(400), + MaxReconnectAttempts = 100, + StopAfterMaxAttempts = false, + ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(3), + ConnectionAttemptTimeout = TimeSpan.FromSeconds(2), + UseCustomPing = false, + }); + await _client.Connect(); + Assert.IsTrue(_client.connection.IsConnected(), "Precondition: connected to the live mock."); + + // Point the client at a port where nothing listens: a reconnect loop starts and retries. + await SwitchTo($"ws://127.0.0.1:{laterPort}"); + Assert.IsFalse(_client.connection.IsConnected(), "Precondition: the target port is closed."); + + // A Connect while that loop is running: it stops the loop, then fails because nothing + // is listening yet. Something must still be reconnecting afterwards. + try + { + await _client.Connect(); + } + catch (Exception) + { + // Expected - nothing is listening on that port yet. + } + + // The server appears. Nobody touches the client from here on. + _secondRippled = StartMock(laterPort); + + DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(40); + while (!_client.connection.IsConnected() && DateTime.UtcNow < deadline) + { + await Task.Delay(TimeSpan.FromMilliseconds(200)); + } + + Assert.IsTrue( + _client.connection.IsConnected(), + "The client never reconnected after the server returned - a failed Connect during " + + "an active reconnect sequence left no loop running."); + } + + private async Task SwitchTo(string url) + { + try + { + await _client.connection.ChangeServer(url); + } + catch (Exception) + { + // Failing to reach a dead port is the point of the race; the invariant is asserted + // by the caller once the dust settles. + } + } + } +} diff --git a/Xrpl/Client/connection.cs b/Xrpl/Client/connection.cs index 2362bc6d..261bfdf0 100644 --- a/Xrpl/Client/connection.cs +++ b/Xrpl/Client/connection.cs @@ -287,10 +287,37 @@ private static WebSocketClient CreateWebSocket(string url, ConnectionOptions con private static readonly Random _random = new(); - // Volatile: StopReconnectLoop, StartReconnectLoop and RetireCurrentSessionAndReconnectAsync - // write this from other threads, and the loop compares it by reference to decide whether it - // still owns the reconnect state. A stale read would let a retired loop run one more - // iteration, or make the owning loop stand down. Matches the other cross-thread fields here. + /// + /// Guards the reconnect session — , and + /// — wherever one is read and another written as a unit: + /// , , + /// , and + /// the ownership-guarded writes in . + /// + /// + /// Not every touch of these fields is covered: the per-iteration _reconnectAttempts++ in + /// , and the plain resets in ChangeServer and + /// OnceClose, still run outside it. Those predate this lock; do not read the list above + /// as "all three fields are always synchronized". + /// + /// + /// + /// volatile alone was not enough: it makes each individual access atomic, not the + /// sequence of them. The stop path used to read the field three times in a row (Cancel, + /// Dispose, null it), so a start running in between could have its brand-new source disposed + /// and cleared by the retiring stop — leaving the loop with a dead source and nobody + /// reconnecting, which is exactly the permanent wedge this whole area exists to prevent. + /// + /// + /// Nothing that can call back into consumer code runs while the lock is held: cancellation and + /// disposal of a retired source happen after the lock is released, and the loop body starts + /// with a yield so that starting it under the lock never runs a notification inline. + /// + /// + private readonly object _reconnectStateLock = new object(); + + // Volatile so the ownership checks in ReconnectLoopAsync can read it outside the lock: + // a single reference read is atomic, and those checks only ever compare, never mutate. private volatile CancellationTokenSource _reconnectCts; private Task _reconnectLoop; @@ -541,15 +568,20 @@ private async Task RetireCurrentSessionAndReconnectAsync(string reason) _reconnectMode = ReconnectMode.FastReconnect; _isFastReconnectActive = true; // Keep for backward compatibility - // 2. Stop any existing reconnect loop - var oldCts = _reconnectCts; + // 2-3. Retire the previous reconnect session and install this one as a single transaction, + // so a concurrent stop/start cannot dispose the source created here. Cancellation and + // disposal of the old source happen after the lock is released. + CancellationTokenSource oldCts; + lock (_reconnectStateLock) + { + oldCts = _reconnectCts; + _reconnectLoop = null; // Clear old loop reference so StartReconnectLoop can start a new one + _reconnectAttempts = 1; + _reconnectCts = new CancellationTokenSource(); + } + oldCts?.Cancel(); oldCts?.Dispose(); - _reconnectLoop = null; // Clear old loop reference so StartReconnectLoop can start a new one - - // 3. Initialize reconnect state BEFORE any notifications - _reconnectAttempts = 1; - _reconnectCts = new CancellationTokenSource(); // 4. Now send first notification - IsReconnectActive() will return true SetConnectionState( @@ -631,10 +663,17 @@ private async Task RetireCurrentSessionAndReconnectAsync(string reason) // Connect succeeded - cleanup reconnect state // Note: _reconnectMode will be cleared in OnceOpen when connection is fully established _isFastReconnectActive = false; - _reconnectCts?.Cancel(); - _reconnectCts?.Dispose(); - _reconnectCts = null; - _reconnectAttempts = 0; + + CancellationTokenSource settled; + lock (_reconnectStateLock) + { + settled = _reconnectCts; + _reconnectCts = null; + _reconnectAttempts = 0; + } + + settled?.Cancel(); + settled?.Dispose(); } catch (Exception ex) { @@ -1797,9 +1836,38 @@ await errorHandler // the backoff at ReconnectBaseDelay. With StopAfterMaxAttempts = false (no give-up branch) // that means connect -> handler failure -> teardown forever at a constant 2s, a sustained // connection load on a node that accepts TCP but cannot serve requests yet. - StopReconnectLoop(); - _reconnectLoop = null; - StartReconnectLoop(initialAttempts: failures); + RestartReconnectLoop(initialAttempts: failures); + } + + /// + /// Retires the current reconnect session and installs a fresh one in a single transaction, + /// seeding the attempt counter with . + /// + /// + /// Doing this as StopReconnectLoop(); _reconnectLoop = null; StartReconnectLoop(seed); + /// took the lock twice with a bare write in between, so a concurrent start (from OnceClose or + /// OnConnectionFailed) could slip in and install its own loop; the seeded start would then see + /// a live loop, return without applying the seed, and the backoff would silently stop growing + /// across consecutive handler failures — the very regression the seed exists to prevent. + /// + private void RestartReconnectLoop(int initialAttempts) + { + CancellationTokenSource retired; + lock (_reconnectStateLock) + { + retired = _reconnectCts; + _reconnectMode = ReconnectMode.LoopReconnect; + _isFastReconnectActive = false; + _reconnectAttempts = initialAttempts; + _reconnectCts = new CancellationTokenSource(); + + // Safe to start under the lock: ReconnectLoopAsync reads its token and yields before + // anything else, so this only schedules the loop - no consumer notification runs inline. + _reconnectLoop = ReconnectLoopAsync(_reconnectCts); + } + + retired?.Cancel(); + retired?.Dispose(); } private async Task OnceClose(int? code, string? description, WebSocketClient closingSocket, long sessionId) @@ -1942,10 +2010,27 @@ private async Task OnceClose(int? code, string? description, WebSocketClient clo private void StopReconnectLoop() { - _reconnectCts?.Cancel(); - _reconnectCts?.Dispose(); - _reconnectCts = null; - _reconnectAttempts = 0; + // Detach under the lock, then cancel/dispose outside it: a start racing with this stop can + // no longer have its fresh source torn down, and cancellation callbacks never run while the + // lock is held. + CancellationTokenSource retired; + lock (_reconnectStateLock) + { + retired = _reconnectCts; + _reconnectCts = null; + _reconnectAttempts = 0; + + // Drop the task reference too, in the same transaction. The retired loop exits + // asynchronously - it only notices it lost ownership on its next check - so leaving the + // reference behind makes StartReconnectLoop see `!IsCompleted` and return without + // starting anything, while the retired loop then stands down on its ownership check. + // Nobody would be reconnecting. Reachable whenever Connect or ChangeServer stops a live + // loop and the new connection fails. + _reconnectLoop = null; + } + + retired?.Cancel(); + retired?.Dispose(); // Note: Do NOT clear _reconnectMode here! // _reconnectMode is cleared only by: // - OnceOpen (connection succeeded) @@ -1976,37 +2061,49 @@ private void StartReconnectLoop(int initialAttempts = 0) { // Set reconnect mode to LoopReconnect (upgrades from FastReconnect or sets from None) _reconnectMode = ReconnectMode.LoopReconnect; - - // CRITICAL: If a loop is already running, don't start another or reset the counter - // This prevents _reconnectAttempts from being reset mid-loop when callbacks trigger - // reconnect logic (OnceClose, OnConnectionFailed, etc.) - var loopIsRunning = _reconnectLoop != null && !_reconnectLoop.IsCompleted; - if (loopIsRunning) - { - // Loop is already running - let it continue, don't reset _reconnectAttempts - return; - } - - // If we have a valid pre-created CTS (from RetireCurrentSessionAndReconnectAsync), - // we should reuse it. Check for this case first. - var existingCts = _reconnectCts; - var hasValidPreCreatedCts = existingCts != null && !existingCts.IsCancellationRequested; - - // If no valid pre-created CTS, create a new one - // Only reset _reconnectAttempts when creating a FRESH CTS (new reconnect sequence) - if (!hasValidPreCreatedCts) + + // The whole decision — is a loop already running, is the current source reusable, install a + // fresh one, hand it to the new loop — is one transaction. Split across the lock it would + // race with StopReconnectLoop and with another start: two loops could end up running, or a + // loop could be handed a source that a concurrent stop has already disposed. + CancellationTokenSource retired = null; + lock (_reconnectStateLock) { - // Cancel/dispose old CTS if any - existingCts?.Cancel(); - existingCts?.Dispose(); - _reconnectCts = new CancellationTokenSource(); - _reconnectAttempts = initialAttempts; + // CRITICAL: If a loop is already running, don't start another or reset the counter + // This prevents _reconnectAttempts from being reset mid-loop when callbacks trigger + // reconnect logic (OnceClose, OnConnectionFailed, etc.) + var loopIsRunning = _reconnectLoop != null && !_reconnectLoop.IsCompleted; + if (loopIsRunning) + { + // Loop is already running - let it continue, don't reset _reconnectAttempts + return; + } + + // If we have a valid pre-created CTS (from RetireCurrentSessionAndReconnectAsync), + // we should reuse it. Check for this case first. + var existingCts = _reconnectCts; + var hasValidPreCreatedCts = existingCts != null && !existingCts.IsCancellationRequested; + + // If no valid pre-created CTS, create a new one + // Only reset _reconnectAttempts when creating a FRESH CTS (new reconnect sequence) + if (!hasValidPreCreatedCts) + { + // Retire the old CTS after the lock is released - see _reconnectStateLock + retired = existingCts; + _reconnectCts = new CancellationTokenSource(); + _reconnectAttempts = initialAttempts; + } + // else: Reuse existing valid CTS (pre-created for fast reconnect) + // Don't reset _reconnectAttempts - this is continuation of existing reconnect sequence + // Note: _reconnectLoop was already cleared by RetireCurrentSessionAndReconnectAsync + + // Safe to start under the lock: ReconnectLoopAsync yields before touching anything, so + // this call only schedules the loop and returns - no consumer notification runs inline. + _reconnectLoop = ReconnectLoopAsync(_reconnectCts); } - // else: Reuse existing valid CTS (pre-created for fast reconnect) - // Don't reset _reconnectAttempts - this is continuation of existing reconnect sequence - // Note: _reconnectLoop was already cleared by RetireCurrentSessionAndReconnectAsync - - _reconnectLoop = ReconnectLoopAsync(_reconnectCts); + + retired?.Cancel(); + retired?.Dispose(); } private async Task ReconnectLoopAsync(CancellationTokenSource ownCts) @@ -2014,8 +2111,20 @@ private async Task ReconnectLoopAsync(CancellationTokenSource ownCts) // The CTS this loop owns. StopReconnectLoop cancels without awaiting the loop, so a retired loop // can still be running - or reach its tail - after a replacement has been installed. Everything // this loop writes to shared reconnect state is therefore guarded by an ownership check. + // + // Read BEFORE the yield below, and deliberately so: the caller still holds + // _reconnectStateLock here, so this source cannot yet have been retired. After the yield a + // concurrent stop may already have disposed it - Cancel/Dispose of a retired source run + // outside the lock - and CancellationTokenSource.Token throws ObjectDisposedException once + // disposed. Taken after the yield, that throw would land outside every try below, faulting + // the loop before its first attempt and vanishing as an unobserved task exception. CancellationToken ct = ownCts.Token; + // Yield so nothing beyond that read runs inline on the caller: StartReconnectLoop starts the + // loop while holding _reconnectStateLock, and a consumer notification executing under that + // lock could deadlock against any path that takes it (Disconnect from a handler, say). + await Task.Yield(); + // Don't reset _reconnectAttempts here - it may be pre-set to 1 by fast reconnect path // StartReconnectLoop() sets it to 0 when creating a new CTS @@ -2078,6 +2187,14 @@ private async Task ReconnectLoopAsync(CancellationTokenSource ownCts) { break; } + catch (ObjectDisposedException) + { + // The source this loop owns was retired and disposed while the delay was being + // set up: registering a callback on a token whose source is gone throws instead + // of cancelling. Same meaning as cancellation - a newer sequence owns the + // reconnect state now - so leave quietly rather than fault the task. + break; + } } if (ct.IsCancellationRequested) @@ -2118,9 +2235,14 @@ private async Task ReconnectLoopAsync(CancellationTokenSource ownCts) if (IsConnected()) { - if (ReferenceEquals(_reconnectCts, ownCts)) + // Ownership check and the write it guards belong together: checked outside the + // lock, this loop could be retired in between and reset a live sequence's counter. + lock (_reconnectStateLock) { - _reconnectAttempts = 0; + if (ReferenceEquals(_reconnectCts, ownCts)) + { + _reconnectAttempts = 0; + } } break; @@ -2170,8 +2292,19 @@ private async Task ReconnectLoopAsync(CancellationTokenSource ownCts) if (config.StopAfterMaxAttempts && _reconnectAttempts >= config.MaxReconnectAttempts) { - _reconnectCts?.Dispose(); - _reconnectCts = null; + // Re-check ownership inside the lock: between the check above and here a new sequence + // could have installed its own source, and disposing that one would strand it. + CancellationTokenSource finished = null; + lock (_reconnectStateLock) + { + if (ReferenceEquals(_reconnectCts, ownCts)) + { + finished = _reconnectCts; + _reconnectCts = null; + } + } + + finished?.Dispose(); } } From ba5e698770c0412ddb00d0a03c27bdc5beeb2180 Mon Sep 17 00:00:00 2001 From: Aleksandr <44946855+Platonenkov@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:18:20 -0300 Subject: [PATCH 08/14] =?UTF-8?q?fix(client):=20=D0=BD=D0=B5=20=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D0=B0=D1=82=D1=8C=20=D1=87=D1=83=D0=B6=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BC=D0=B5=D0=BD=D1=8B=20=D0=B2=20=D0=B1=D1=8B?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=BC=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B8=20?= =?UTF-8?q?(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(client): не трогать чужой источник отмены в быстром переподключении Замечания CodeRabbit к релизному мержу. Все пять проверены по актуальному коду, все оказались в силе. * Ветка успеха RetireCurrentSessionAndReconnectAsync безусловно забирала _reconnectCts и диспозила его. За время await ConnectInternalAsync и WaitForConnectionAsync конкурентный путь (RestartReconnectLoop из падающего OnConnected-хендлера) успевает установить НОВЫЙ источник — и его отменяли вместе с обнулением счётчика, оставляя новую последовательность без источника. Метод теперь запоминает созданный им самим источник и снимает состояние только если _reconnectCts всё ещё ссылается на него — так же, как проверки владения в ReconnectLoopAsync * StartReconnectLoop: параметр initialAttempts стал мёртвым после появления RestartReconnectLoop (все три вызова шли без аргумента), удалён вместе с документацией; свежая последовательность начинается с нуля явно * TestUOnConnectedHandlerFailure: комментарий к ассерту роста бэкоффа врал. С ReconnectBaseDelay 100 мс и ReconnectMaxDelay 1 с задержки идут 400, 800, 1000 мс — третья упирается в cap, то есть первый-к-последнему это ~2.5x, а не 4x. Добавлено, что ассерт держится лишь пока cap выше более ранних значений: при cap 400 мс все интервалы легли бы на него и сравнялись * TestUReconnectSessionRaces: laterPort выдаётся до await'ов, поэтому перед StartMock добавлена проверка IsPortStillFree — иначе занятый порт проявился бы таймаутом финального ассерта, а не внятным сообщением * Blazor-демо: client.connection.GetUrl() заменён на client.Url() во всех трёх оставшихся местах — интерфейсный метод, и он же уже использовался в правке смены сервера Гонку из первого пункта тестом не воспроизвести: нужно попасть в окно между await'ами, а через публичный API оно недостижимо — с убранной проверкой владения все тесты переподключения проходят (проверено мутацией). Правка обоснована тем же инвариантом, что и остальные проверки владения в этом файле. Проверено: сборка решения без ошибок, юнит-тесты 883/883. * fix(client): снимать ссылку на цикл и в ветке успеха быстрого переподключения Селф-ревью PR #79 тремя независимыми проходами (Opus) — нашлось одно блокирующее и четыре неточности в моих же комментариях. Блокирующее: ветка успеха RetireCurrentSessionAndReconnectAsync забирала _reconnectCts, но оставляла _reconnectLoop — та же дыра, которую PR #78 закрыл в StopReconnectLoop, и здесь я её не заметил. Метод обнуляет _reconnectLoop на входе, поэтому за время await'ов конкурентный OnConnectionFailed видит «цикла нет», а StartReconnectLoop переиспользует ещё валидный ownCts и ставит на него новый цикл. Дальше ветка успеха отменяла источник, но ссылку на задачу оставляла: все проверки loopIsRunning видели выходящую задачу и отступали, а сам цикл вставал по проверке владения. Переподключаться было некому. Ссылка теперь снимается в той же транзакции. Неточности комментариев, все мои: * обоснование guard'а порта было скопировано из TestUChangeServerFailure, где StartMock уходит в фоновый поток. В этом файле он теперь синхронный (я сам так сделал по прошлому нитпику), поэтому занятый порт даёт SocketException сразу, а не таймаут ассерта. Плюс отмечено, что guard — диагностика: он сам биндит и отпускает, TOCTOU остаётся * XML-док StartReconnectLoop обещал «always begins at the base delay». Счётчик свежей последовательности равен нулю, инкремент идёт до расчёта, значит первая задержка — CalcBackoff(1), то есть вдвое больше базовой; а на путях ping-timeout и network-drop первая попытка вообще без задержки * remarks у _reconnectStateLock перечисляли известные исключения, но пропускали предпроверки «цикл уже идёт?» в OnConnectionFailed и OnceClose — они читают невалатильный _reconnectLoop вне лока * комментарий в catch-ветке утверждал, что источник заведомо на месте и будет переиспользован, — ровно то допущение, которое ветка успеха теперь объявляет неверным. Переписан с разбором обоих исходов * добавлена явная фраза, что при потере владения ownCts освобождать не нужно: его отменил и задиспозил тот, кто вытеснил Проверено: сборка решения без ошибок, юнит-тесты 883/883. Живой прогон Blazor-демо против локальной ноды 3.2.1 — обрыв соединения (нода убита при активном клиенте, код 1005) даёт восстановление следующей же попыткой после подъёма ноды, отключение/подключение, переключения на мёртвые порты и возврат на живой проходят, стрим ledger-ов идёт, необработанных исключений в консоли нет. * fix(client): не терять цикл на исключении потребителя и не оживлять после Disconnect Два предсуществующих дефекта в RetireCurrentSessionAndReconnectAsync, найденные селф-ревью PR #79. Оба в том же методе, чей жизненный цикл этот PR и приводит в порядок, поэтому закрываются здесь же. 1. Исключение из потребительского обработчика уносило сессию с собой. SetConnectionState синхронно зовёт OnConnectionStatus, а метод исполняется только на ping-путях, где вызывающие глушат всё. Бросок из обработчика вылетал из метода, оставляя установленный источник отмены без цикла и без владельца: клиент замирал в RestoringConnection, а CheckIfNotConnected по непустому _reconnectCts считал, что попытка идёт, из-за чего WaitForConnectionAsync выжигал весь ConnectionAcquisitionTimeout. Обе нотификации обёрнуты. В catch-ветке вдобавок изменён порядок: StartReconnectLoop вызывается ДО уведомления — без цикла клиент не вернётся, а сообщение вторично. 2. Метод оживлял клиент после пользовательского Disconnect(). Disconnect ставит _permanentlyDisconnected, чистит состояние переподключения и ждёт ping-задачу — ту самую, внутри которой крутится этот метод, то есть он ещё не завершился. Метод же безусловно сбрасывал флаг и поднимал новую сессию: Disconnect возвращался с отчётом об успехе, пока за ним собиралось живое соединение. Теперь флаг проверяется перед сбросом: если пользователь отключился, метод освобождает свой источник (по проверке владения) и уходит. Та же проверка добавлена в catch-ветку перед запуском цикла. Тестами эти пути не покрыты, и это не оговорка: все три вызова метода — на ping-путях, а во всех юнит-тестах клиента стоит UseCustomPing = false, то есть метод в тестах не исполняется вовсе. Тест возможен (UseCustomPing = true плюс мок, не отвечающий на ping), но интервал пинга захардкожен 20 с в трёх местах и конфигом не управляется, так что прогон вышел бы на 25-40 с. Дешёвый тест появится, если интервал сделать настраиваемым — отдельная задача. Проверено: сборка решения без ошибок, юнит-тесты 883/883. * refactor(client): вынести интервал и порог health-check в конфиг Проверка живости соединения жила на двух захардкоженных числах: интервал таймера 20 с (в трёх местах — обычный Timer и WASM-таймер) и порог неактивности 60 с. Теперь это HealthCheckInterval и InactivityTimeout в ConnectionOptions, значения по умолчанию прежние. Мотивация — тестируемость fast-reconnect. Все вызовы RetireCurrentSessionAndReconnectAsync стоят за этим таймером, поэтому путь, правленный в трёх PR подряд (#72, #78, #79), ни разу не исполнялся ни в одном тесте: во всех тестах клиента UseCustomPing = false. С настраиваемыми порогами он достижим за миллисекунды вместо минуты. Тесты этим коммитом НЕ добавлены, и вот почему: воспроизвести обрыв на CreateMockRippled не удалось. mock.Stop() гасит listener, но клиентский ClientWebSocket остаётся в состоянии Open — он узнаёт о смерти пира только на следующем вводе-выводе, а ошибка keepalive-пинга состояние не меняет. Пробовал паузы до 8 с: клиент так и не входит в RestoringConnection, то есть тест зелёный на несработавшем сценарии — хуже, чем никакого. Ветка порога неактивности тоже недостижима: мок отвечает даже на неизвестные команды, поэтому активность обновляется каждый тик и порог не переступается. Чтобы покрыть путь по-настоящему, нужно одно из двух: научить мок обрывать соединение (сейчас у него только Start/Stop) или поставить в тестах TCP-прокси между клиентом и сервером и рвать его. Обе опции — отдельная задача; эти два свойства делают её выполнимой и попутно убирают магические числа. Проверено: сборка решения без ошибок, юнит-тесты 883/883. * fix(client): гасить исключения нотификации в одной точке и валидировать health-check Три замечания CodeRabbit к PR #79. 1. (Major) Исключение обработчика внутри ReconnectLoopAsync. В прошлом коммите я обернул нотификации в fast-reconnect, но сам цикл переподключения тоже зовёт SetConnectionState — перед первой попыткой соединения. Бросок из OnConnectionStatus ронял задачу цикла, оставляя _reconnectCts установленным без живого цикла: восстанавливать соединение становилось некому. Латать по местам бессмысленно — защита перенесена в саму SetConnectionState, через которую проходят все уведомления класса. Две локальные обёртки убраны как избыточные; обработчик потребителя больше не может уронить машину состояний ниоткуда. 2. (Major, частично) Пользовательский Disconnect против летящего fast reconnect. ConnectInternalAsync и WaitForConnectionAsync теперь получают токен сессии, которой владеет метод: Disconnect отменяет её, и попытка прекращается вместо открытия сокета за спиной у отключённого клиента. Проверки флага для этого недостаточно — Disconnect ждёт ping-задачу считанные секунды, а получение соединения может длиться дольше. Полностью замечание не закрыто: остаётся принципиальный вопрос, вправе ли автоматические пути вообще сбрасывать _permanentlyDisconnected, — это отдельная задача, здесь взято то, что устраняет главное окно. 3. (Minor) Валидация новых опций. HealthCheckInterval на WASM-пути кастится в int миллисекунд: ноль выстреливает один раз и больше не повторяется, значения вне диапазона таймер отвергает сам. Оба свойства проверяются в ValidateConfig, то есть в конструкторе Connection, с указанием имени опции. TestUHealthCheckOptions покрывает границы: ноль, отрицательное, за пределами int.MaxValue, неположительный InactivityTimeout, и приёмку 1 мс и значений по умолчанию. Проверено: сборка решения без ошибок, юнит-тесты 888/888. --- .../Blazor-WebAssembly/Pages/Index.razor | 6 +- .../Client/TestUHealthCheckOptions.cs | 95 ++++++++ .../Client/TestUOnConnectedHandlerFailure.cs | 9 +- .../Client/TestUReconnectSessionRaces.cs | 8 + Xrpl/Client/connection.cs | 204 +++++++++++++++--- 5 files changed, 282 insertions(+), 40 deletions(-) create mode 100644 Tests/Xrpl.Tests/Client/TestUHealthCheckOptions.cs diff --git a/Tests/TestsClients/Blazor-WebAssembly/Pages/Index.razor b/Tests/TestsClients/Blazor-WebAssembly/Pages/Index.razor index e2eee217..0a213b84 100644 --- a/Tests/TestsClients/Blazor-WebAssembly/Pages/Index.razor +++ b/Tests/TestsClients/Blazor-WebAssembly/Pages/Index.razor @@ -397,7 +397,7 @@ await InvokeAsync(async () => { IsConnected = true; - CurrentServerUrl = client.connection.GetUrl(); + CurrentServerUrl = client.Url(); CurrentReconnectInfo = null; SessionStartTime = DateTime.Now; @@ -617,7 +617,7 @@ client.connection.OnTransaction += _onTransactionHandler; client.connection.OnLedgerClosed += _onLedgerClosedHandler; - CurrentServerUrl = client.connection.GetUrl(); + CurrentServerUrl = client.Url(); CurrentConnectionState = client.connection.CurrentConnectionState; await base.OnInitializedAsync(); @@ -627,7 +627,7 @@ if (client.connection.IsConnected()) { IsConnected = true; - CurrentServerUrl = client.connection.GetUrl(); + CurrentServerUrl = client.Url(); CurrentConnectionState = client.connection.CurrentConnectionState; StateHasChanged(); } diff --git a/Tests/Xrpl.Tests/Client/TestUHealthCheckOptions.cs b/Tests/Xrpl.Tests/Client/TestUHealthCheckOptions.cs new file mode 100644 index 00000000..f8f18fd3 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestUHealthCheckOptions.cs @@ -0,0 +1,95 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Threading.Tasks; + +using Xrpl.Client; + +using XrplTests; + +namespace Xrpl.Tests +{ + /// + /// Boundary checks for the health-check timing options. Both feed a timer directly — + /// HealthCheckInterval is cast to an int of milliseconds on the WASM path, where zero + /// fires once and never repeats and out-of-range values are rejected by the timer itself — so a + /// bad value has to fail on the way in, naming the option, rather than quietly disabling the + /// check that recovers dead connections. + /// + [TestClass] + public class TestUHealthCheckOptions + { + private static XrplClient CreateClient(TimeSpan? healthCheckInterval = null, TimeSpan? inactivityTimeout = null) => + new XrplClient("ws://127.0.0.1:1", new XrplClient.ClientOptions + { + UseCustomPing = true, + UseCheckHealth = true, + HealthCheckInterval = healthCheckInterval ?? TimeSpan.FromSeconds(20), + InactivityTimeout = inactivityTimeout ?? TimeSpan.FromSeconds(60), + ConnectionAttemptTimeout = TimeSpan.FromSeconds(1), + ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(1), + }); + + [TestMethod] + public void TestZeroHealthCheckIntervalIsRejected() + { + // Validation runs while the connection is being constructed, so the bad value is rejected + // before a client exists to connect with. + ArgumentException error = Helper.ThrowsException( + () => CreateClient(healthCheckInterval: TimeSpan.Zero)); + StringAssert.Contains(error.Message, "HealthCheckInterval"); + } + + [TestMethod] + public void TestNegativeHealthCheckIntervalIsRejected() + { + ArgumentException error = Helper.ThrowsException( + () => CreateClient(healthCheckInterval: TimeSpan.FromMilliseconds(-1))); + StringAssert.Contains(error.Message, "HealthCheckInterval"); + } + + [TestMethod] + public void TestOutOfRangeHealthCheckIntervalIsRejected() + { + // Past int.MaxValue milliseconds - the WASM timer cannot represent it + ArgumentException error = Helper.ThrowsException( + () => CreateClient(healthCheckInterval: TimeSpan.FromDays(30))); + StringAssert.Contains(error.Message, "HealthCheckInterval"); + } + + [TestMethod] + public void TestNonPositiveInactivityTimeoutIsRejected() + { + ArgumentException error = Helper.ThrowsException( + () => CreateClient(inactivityTimeout: TimeSpan.Zero)); + StringAssert.Contains(error.Message, "InactivityTimeout"); + } + + /// + /// The lower bound is 1ms, and the defaults must keep working — otherwise every existing + /// consumer would start failing on connect. + /// + [TestMethod] + public async Task TestBoundaryAndDefaultValuesAreAccepted() + { + // 1ms is the documented minimum: validation must let it through. Nothing is listening on + // port 1, so the connect attempt fails on the transport - not on config validation. + XrplClient atMinimum = CreateClient( + healthCheckInterval: TimeSpan.FromMilliseconds(1), + inactivityTimeout: TimeSpan.FromMilliseconds(1)); + + Exception minimumError = await Helper.ThrowsExceptionAsync(() => atMinimum.Connect()); + Assert.IsNotInstanceOfType( + minimumError, + typeof(ArgumentException), + $"1ms should pass validation, but connect failed with: {minimumError.Message}"); + + XrplClient atDefaults = CreateClient(); + Exception defaultError = await Helper.ThrowsExceptionAsync(() => atDefaults.Connect()); + Assert.IsNotInstanceOfType( + defaultError, + typeof(ArgumentException), + $"The default options should pass validation, but connect failed with: {defaultError.Message}"); + } + } +} diff --git a/Tests/Xrpl.Tests/Client/TestUOnConnectedHandlerFailure.cs b/Tests/Xrpl.Tests/Client/TestUOnConnectedHandlerFailure.cs index c8c372e5..a47f8a46 100644 --- a/Tests/Xrpl.Tests/Client/TestUOnConnectedHandlerFailure.cs +++ b/Tests/Xrpl.Tests/Client/TestUOnConnectedHandlerFailure.cs @@ -298,8 +298,13 @@ public async Task TestRepeatedOnConnectedFailuresBackOff() } } - // ReconnectBaseDelay is 100ms and CalcBackoff doubles per attempt with 25% jitter, - // so the third gap is ~4x the first even at the extremes of the jitter range. + // CalcBackoff doubles per attempt off ReconnectBaseDelay (100ms), capped at + // ReconnectMaxDelay (1s), with 25% jitter. The handler-failure path seeds the counter + // with its consecutive-failure count, so the delays run 400ms, 800ms, then 1s (capped) + // — first to last is ~2.5x nominally, and still grows at the jitter extremes. + // This holds only while the configured cap stays above the earlier backoff values: with + // a cap at or below 400ms every gap would sit on the cap, and the comparison would come + // down to which way the jitter fell — a coin flip, not a stable result. // Comparing first vs last rather than each consecutive pair keeps the assertion // robust: what regressed before was a flat sequence, not the exact multiplier. Assert.IsTrue( diff --git a/Tests/Xrpl.Tests/Client/TestUReconnectSessionRaces.cs b/Tests/Xrpl.Tests/Client/TestUReconnectSessionRaces.cs index 7d434a45..1ce93c07 100644 --- a/Tests/Xrpl.Tests/Client/TestUReconnectSessionRaces.cs +++ b/Tests/Xrpl.Tests/Client/TestUReconnectSessionRaces.cs @@ -265,6 +265,14 @@ public async Task TestFailedConnectDuringReconnectLeavesLoopRunning() } // The server appears. Nobody touches the client from here on. + // The port was handed out before the awaits above, so check it is still free. StartMock + // binds on this thread, so a port taken meanwhile would come out as a raw SocketException + // from the line below; this turns it into a statement of the actual cause. Diagnostics, + // not a fix: the check itself binds and releases, so the port can still be lost between + // here and StartMock. + Assert.IsTrue( + TestUtils.IsPortStillFree(laterPort), + $"Port {laterPort} was taken by another process while the test held it — rerun."); _secondRippled = StartMock(laterPort); DateTime deadline = DateTime.UtcNow + TimeSpan.FromSeconds(40); diff --git a/Xrpl/Client/connection.cs b/Xrpl/Client/connection.cs index 261bfdf0..84afbe80 100644 --- a/Xrpl/Client/connection.cs +++ b/Xrpl/Client/connection.cs @@ -212,6 +212,32 @@ public class ConnectionOptions ///
public bool UseCheckHealth { get; set; } = false; + /// + /// Gets or sets how often the background health check runs — the timer that notices a socket + /// which is no longer Open and hands the client to the fast-reconnect path.
+ /// Default: 20 seconds, the interval this check has always used. + ///
+ /// + /// Exposed primarily so tests can exercise the ping and fast-reconnect paths without waiting + /// out the default interval; those paths were previously unreachable from a unit test, which + /// is why they went uncovered through several fixes. Lowering it in production only makes the + /// state check more frequent — it sends no network requests of its own. + /// + public TimeSpan HealthCheckInterval { get; set; } = TimeSpan.FromSeconds(20); + + /// + /// Gets or sets how long a connection may go without any inbound activity before the health + /// check treats it as dead and hands it to the fast-reconnect path.
+ /// Default: 60 seconds, the threshold this check has always used. + ///
+ /// + /// A socket whose peer vanished stays Open until the next I/O, so silence is the only + /// signal available without sending traffic. Exposed together with + /// so the fast-reconnect path is reachable from a test in + /// under a second instead of over a minute. + /// + public TimeSpan InactivityTimeout { get; set; } = TimeSpan.FromSeconds(60); + /// /// Gets or sets the policy that determines how failed requests are handled. /// @@ -237,6 +263,23 @@ private void ValidateConfig() throw new ArgumentException( $"ConnectionAcquisitionTimeout ({config.ConnectionAcquisitionTimeout.TotalSeconds}s) must be >= ConnectionAttemptTimeout ({config.ConnectionAttemptTimeout.TotalSeconds}s) to allow at least one full connection attempt."); } + + // The WASM timer takes this as an int of milliseconds: zero fires once and never repeats, + // and anything past int.MaxValue or below zero is rejected outright by the timer itself. + // Fail here instead, where the message can say which option is wrong. + double healthCheckMs = config.HealthCheckInterval.TotalMilliseconds; + if (healthCheckMs < 1 || healthCheckMs > int.MaxValue) + { + throw new ArgumentException( + $"HealthCheckInterval ({config.HealthCheckInterval}) must be between 1ms and {int.MaxValue}ms."); + } + + if (config.InactivityTimeout <= TimeSpan.Zero) + { + throw new ArgumentException( + $"InactivityTimeout ({config.InactivityTimeout}) must be positive - a non-positive value would " + + "treat every connection as dead on the first health check."); + } } // https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/src/client/connection.ts createWebSocket @@ -296,9 +339,11 @@ private static WebSocketClient CreateWebSocket(string url, ConnectionOptions con ///
/// /// Not every touch of these fields is covered: the per-iteration _reconnectAttempts++ in - /// , and the plain resets in ChangeServer and - /// OnceClose, still run outside it. Those predate this lock; do not read the list above - /// as "all three fields are always synchronized". + /// , the plain resets in ChangeServer and + /// OnceClose, and the "is a loop already running" pre-checks in + /// OnConnectionFailed and OnceClose (which read _reconnectLoop, a + /// non-volatile field, outside the lock) all still run outside it. Those predate this lock; do + /// not read the list above as "all three fields are always synchronized". /// /// /// @@ -405,14 +450,27 @@ private void SetConnectionState( _previousNotifiedMessage = message; - OnConnectionStatus?.Invoke( - new ConnectionStatusInfo - { - Message = message, - Severity = severity, - Reconnect = reconnect, - ConnectionState = newState, - }); + // Contained here, once, rather than at each call site. Every state notification in this class + // funnels through this method, and several call sites are places where an escaping exception + // costs the client its reconnect: the fast-reconnect path (running on a ping task whose + // callers swallow everything) and ReconnectLoopAsync, which notifies before its first + // connection attempt and would fault with _reconnectCts still installed and no live loop. + // A consumer's status handler must not be able to take the connection down. + try + { + OnConnectionStatus?.Invoke( + new ConnectionStatusInfo + { + Message = message, + Severity = severity, + Reconnect = reconnect, + ConnectionState = newState, + }); + } + catch (Exception notifyError) + { + Debug.WriteLine($"{DateTime.Now}OnConnectionStatus handler threw for state {newState}: {notifyError.Message}"); + } } private ReconnectInfo BuildReconnectInfo(int? explicitAttempt = null, TimeSpan? delay = null) @@ -572,18 +630,22 @@ private async Task RetireCurrentSessionAndReconnectAsync(string reason) // so a concurrent stop/start cannot dispose the source created here. Cancellation and // disposal of the old source happen after the lock is released. CancellationTokenSource oldCts; + CancellationTokenSource ownCts; lock (_reconnectStateLock) { oldCts = _reconnectCts; _reconnectLoop = null; // Clear old loop reference so StartReconnectLoop can start a new one _reconnectAttempts = 1; - _reconnectCts = new CancellationTokenSource(); + ownCts = new CancellationTokenSource(); + _reconnectCts = ownCts; } oldCts?.Cancel(); oldCts?.Dispose(); // 4. Now send first notification - IsReconnectActive() will return true + // Consumer handler exceptions are contained inside SetConnectionState - an escaping throw + // here would leave the source installed above with no loop and nobody to dispose it. SetConnectionState( XrpConnectionState.RestoringConnection, message: $"{reason} Reconnecting immediately...", @@ -646,7 +708,34 @@ private async Task RetireCurrentSessionAndReconnectAsync(string reason) _pingTimeoutSocket = null; _networkDropSocket = null; - // 11. Reset permanentlyDisconnected for new connection + // 11. Reset permanentlyDisconnected for new connection - unless the user asked to disconnect + // while the awaits above were running. Disconnect() sets the flag, clears the reconnect + // state and then waits on the ping task this method runs inside, so it is still blocked + // here and cannot have finished its teardown. Clearing its flag and reconnecting anyway + // would resurrect a client the consumer explicitly took down - and Disconnect() would + // return reporting success while a fresh session was being built behind it. + if (_permanentlyDisconnected) + { + CancellationTokenSource abandoned = null; + lock (_reconnectStateLock) + { + if (ReferenceEquals(_reconnectCts, ownCts)) + { + abandoned = ownCts; + _reconnectCts = null; + _reconnectAttempts = 0; + _reconnectLoop = null; + } + } + + abandoned?.Cancel(); + abandoned?.Dispose(); + _isFastReconnectActive = false; + + Debug.WriteLine($"{DateTime.Now}Fast reconnect abandoned before connecting - the client was disconnected by the user"); + return; + } + _permanentlyDisconnected = false; // Note: _reconnectAttempts and _reconnectCts already set at the start of this method @@ -657,19 +746,41 @@ private async Task RetireCurrentSessionAndReconnectAsync(string reason) // and Connecting would overwrite ReconnectInfo, confusing consuming apps try { - await ConnectInternalAsync().ConfigureAwait(false); - await WaitForConnectionAsync(config.ConnectionAcquisitionTimeout, CancellationToken.None).ConfigureAwait(false); + // Pass the token of the session this method owns: a user Disconnect() cancels it, so the + // attempt below stops instead of opening a socket behind a client that was taken down. + // Disconnect() waits only briefly for the ping task, while acquisition can run much + // longer, so the flag check above cannot cover this window on its own. + await ConnectInternalAsync(ownCts.Token).ConfigureAwait(false); + await WaitForConnectionAsync(config.ConnectionAcquisitionTimeout, ownCts.Token).ConfigureAwait(false); // Connect succeeded - cleanup reconnect state // Note: _reconnectMode will be cleared in OnceOpen when connection is fully established _isFastReconnectActive = false; - CancellationTokenSource settled; + // Only tear down the source this method installed. The awaits above give a concurrent + // path (RestartReconnectLoop from a failing OnConnected handler, say) room to install a + // newer one; cancelling and disposing that would strand the sequence it belongs to, + // which is the same wedge the ownership checks in ReconnectLoopAsync guard against. + // When ownership is lost, ownCts needs no cleanup here: whoever evicted it from the + // field cancelled and disposed it as part of doing so. + CancellationTokenSource settled = null; lock (_reconnectStateLock) { - settled = _reconnectCts; - _reconnectCts = null; - _reconnectAttempts = 0; + if (ReferenceEquals(_reconnectCts, ownCts)) + { + settled = ownCts; + _reconnectCts = null; + _reconnectAttempts = 0; + + // Drop the task reference in the same transaction, for the same reason + // StopReconnectLoop does: a loop may have been started on this very source + // while the awaits above were running (OnConnectionFailed sees no live loop - + // the entry above cleared the reference - and StartReconnectLoop reuses a + // still-valid source). Cancelling that source without clearing the reference + // leaves every loopIsRunning check looking at a task that is exiting, so + // nobody starts a replacement and nobody reconnects. + _reconnectLoop = null; + } } settled?.Cancel(); @@ -680,13 +791,33 @@ private async Task RetireCurrentSessionAndReconnectAsync(string reason) // If Connect fails, transition to loop reconnect mode // Keep _reconnectMode set (will be LoopReconnect after StartReconnectLoop) - // _reconnectCts is already set, so StartReconnectLoop will reuse it + // A user Disconnect() can land while the awaits above are running - and it will wait on + // the very ping task this method runs inside, so it cannot have finished yet. Handing + // the client back to a reconnect loop then would undo an explicit disconnect. The flag + // is the authority: leave the state alone and let Disconnect() finish its teardown. + if (_permanentlyDisconnected) + { + Debug.WriteLine($"{DateTime.Now}Fast reconnect abandoned - the client was disconnected by the user: {ex.Message}"); + return; + } + + // Start the loop BEFORE notifying: SetConnectionState calls into consumer code, and an + // exception from a handler must not cost us the reconnect loop. Ordering matters more + // than the message here - without the loop the client never comes back. + // + // StartReconnectLoop reuses the source installed above when it is still there. It may + // not be: the awaits could have let another path replace or clear it, the same way the + // success branch above can no longer assume it still owns ownCts. Either outcome is + // survivable here - a live foreign loop makes the call return early, a cleared source + // makes it start a fresh sequence (losing only the seeded first delay) - so this path + // does not need an ownership check of its own. + StartReconnectLoop(); + SetConnectionState( XrpConnectionState.RestoringConnection, message: $"Reconnection failed: {ex.Message}. Retrying...", ConnectionCloseSeverity.Warning, reconnect: BuildReconnectInfo()); - StartReconnectLoop(); } } @@ -2050,14 +2181,15 @@ private void ClearReconnectState() _reconnectMode = ReconnectMode.None; } - /// - /// Value to seed _reconnectAttempts with when a fresh reconnect sequence starts. - /// Defaults to 0 — a genuine new sequence begins at the base delay. The OnConnected-handler - /// path passes its own consecutive-failure count instead: that path tears the loop down and - /// starts it again on every failure, so with a 0 seed the backoff would restart at - /// ReconnectBaseDelay each time and never grow. - /// - private void StartReconnectLoop(int initialAttempts = 0) + /// + /// Starts a reconnect loop unless one is already running, reusing a pre-created cancellation + /// source when there is one. A fresh sequence starts its attempt counter at zero, so the first + /// delay is CalcBackoff(1) — twice ReconnectBaseDelay — except on the + /// ping-timeout and network-drop paths, where the first attempt skips the delay entirely. The + /// OnConnected-handler path needs a seeded counter instead and uses + /// . + /// + private void StartReconnectLoop() { // Set reconnect mode to LoopReconnect (upgrades from FastReconnect or sets from None) _reconnectMode = ReconnectMode.LoopReconnect; @@ -2091,7 +2223,7 @@ private void StartReconnectLoop(int initialAttempts = 0) // Retire the old CTS after the lock is released - see _reconnectStateLock retired = existingCts; _reconnectCts = new CancellationTokenSource(); - _reconnectAttempts = initialAttempts; + _reconnectAttempts = 0; } // else: Reuse existing valid CTS (pre-created for fast reconnect) // Don't reset _reconnectAttempts - this is continuation of existing reconnect sequence @@ -2333,8 +2465,8 @@ private void StartWasmPingTimer(CancellationTokenSource cts) _ = ExecutePingCheckAndReleaseAsync(innerCts, tcs); }, state: cts, - dueTime: 20000, - period: 20000); + dueTime: (int)config.HealthCheckInterval.TotalMilliseconds, + period: (int)config.HealthCheckInterval.TotalMilliseconds); } private async Task ExecutePingCheckAndReleaseAsync(CancellationTokenSource cts, TaskCompletionSource tcs) @@ -2403,11 +2535,13 @@ private async Task ExecutePingCheckAsync(CancellationTokenSource cts) return; } - if (timeSinceLastActivity > 60) + double inactivityLimit = config.InactivityTimeout.TotalSeconds; + if (timeSinceLastActivity > inactivityLimit) { _pingTimeoutSocket = ws; - await RetireCurrentSessionAndReconnectAsync("Connection timeout (no activity for 60+ seconds)."); + await RetireCurrentSessionAndReconnectAsync( + $"Connection timeout (no activity for {inactivityLimit:F0}+ seconds)."); return; } @@ -2518,7 +2652,7 @@ private void StartPingTimer() } else { - pingTimer = new Timer(20000); + pingTimer = new Timer(config.HealthCheckInterval.TotalMilliseconds); pingTimer.Elapsed += (sender, e) => { if (cts.IsCancellationRequested) From af28b4b105a26ed52adadc284a9e5a062fe3ba96 Mon Sep 17 00:00:00 2001 From: Aleksandr <44946855+Platonenkov@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:14:19 -0300 Subject: [PATCH 09/14] =?UTF-8?q?feat(sugar):=20=D0=BA=D0=BE=D0=BC=D0=B8?= =?UTF-8?q?=D1=81=D1=81=D0=B8=D0=B8=20LoanSet/LoanPay/ConfidentialMPT=20?= =?UTF-8?q?=D0=B2=20Autofill=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sugar): комиссии LoanSet/LoanPay/ConfidentialMPT в Autofill Три транзактора со специальной базовой комиссией считались как обычные, и все три — в сторону НЕДОплаты: комиссия ниже требуемого минимума отклоняется с telINSUF_FEE_P, а не дотягивается сетью. LoanSet: плоское baseFee*2 верно только при одиночной подписи контрагента. LoanSet::calculateBaseFee берёт по одной базовой комиссии за каждую запись CounterpartySignature.Signers. Если подпись уже проставлена — считаем её записи напрямую; если нет (обычный порядок в autofill) — запрашиваем signer list контрагента, как это делает xrpl.js. LoanPay: LoanPay::calculateBaseFee умножает ВЕСЬ Transactor-костыль, включая подписи, на один инкремент за каждые kLoanPaymentsPerFeeIncrement (5) платежей, с потолком kLoanMaximumPaymentsPerTransaction/5 (20). Оценка читает объект Loan, считает платёж как roundPeriodicPayment(PeriodicPayment, LoanScale) + LoanServiceFee и делит на него Amount транзакции. Все ветки, где rippled возвращает normalCost, воспроизведены: tfLoanFullPayment/tfLoanLatePayment, PaymentRemaining <= 5 и недоступный объект Loan. ConfidentialMPT (5 типов): Transactor::calculateBaseFee вызывается с kConfidentialFeeMultiplier = 9 — десять базовых комиссий за одиночно подписанную транзакцию. Автозаполнялись одной, т.е. в 10 раз меньше. 12 юнит-тестов на новые формулы; весь набор TestU (895) зелёный. * fix(sugar): current-леджер и защита от переполнения в комиссиях займов Замечания CodeRabbit к PR #81. account_info контрагента LoanSet и ledger_entry объекта Loan запрашивались на validated-леджере. SignerListSet или LoanSet, попавшие в последний леджер, там ещё не видны: в первом случае комиссия считается по одной подписи вместо списка, во втором объект Loan выглядит недоступным и множитель платежей теряется — обе ошибки в сторону недоплаты. Оба запроса переведены на current, как это уже делает SetNextValidSequenceNumber. Проверка потолка платежей масштабировала regularPayment вверх: PeriodicPayment у верхней границы decimal ронял autofill с OverflowException. Сравнение переставлено на деление amount на константу, которое переполниться не может. Кламп оценки платежей по PaymentRemaining, предложенный в том же ревью, не применён: LoanPay::calculateBaseFee читает sfPaymentRemaining только как short-circuit <= 5 и не ограничивает им оценку. Кламп дал бы 2 инкремента там, где сеть требует 20, то есть недоплату и telINSUF_FEE_P. Зафиксировано тестом FewPaymentsRemainingWithLargeAmount. --- CHANGES.md | 5 + Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs | 294 +++++++++++++++++- Xrpl/Sugar/Autofill.cs | 328 +++++++++++++++++++- 3 files changed, 608 insertions(+), 19 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index e4603159..9b0ed1a7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,11 @@ ## 10.11.0.0 08/04/2026 +* **Autofill covers the three remaining transactors with a special base fee** — `Transactor::calculateBaseFee` is overridden by ten transactors upstream, and the fee sugar implemented only some of them. The three that were missing all **underpay**, which is the failing direction: a fee below the required minimum is rejected with `telINSUF_FEE_P` instead of being topped up. Each is verified against the rippled source rather than inferred from the field layout: + * **`LoanSet` no longer assumes a single counterparty signature.** The old formula was a flat `baseFee * 2`, correct only when the counterparty signs with its master key. `LoanSet::calculateBaseFee` charges one base fee per entry of `CounterpartySignature.Signers`, so a counterparty that multi-signs made the autofilled fee too low. When the signature is already attached — `LoanSigningHelper` ran first — its signers are counted directly; when it is not, which is the usual order during autofill, the counterparty's signer list size is fetched and used, matching what xrpl.js does for the same transaction. Absent signer list, or an account that does not exist yet, falls back to one signature + * **`LoanPay` charges per five payments processed.** `LoanPay::calculateBaseFee` multiplies the *whole* Transactor cost — signatures included — by one increment per `kLoanPaymentsPerFeeIncrement` (5) payments the transaction is expected to make, capped at `kLoanMaximumPaymentsPerTransaction / 5` (20). Paying off six or more scheduled payments in one transaction therefore costs at least twice the base fee, and nothing in the SDK accounted for it. The estimate reads the `Loan` object, derives the per-payment amount as `roundPeriodicPayment(PeriodicPayment, LoanScale) + LoanServiceFee` — rounding up to whole units for XRP and MPT, to a multiple of `10^LoanScale` for IOUs, as `roundToAsset` does — and divides the transaction `Amount` by it. Every path rippled short-circuits is mirrored: `tfLoanFullPayment` and `tfLoanLatePayment` do one set of calculations, `PaymentRemaining <= 5` needs no increments, and an unreadable `Loan` object falls back to the normal cost the same way rippled leaves the error to preclaim. The asset's integrality is taken from the transaction's own `Amount`, which rippled requires to match the vault asset, so no broker/vault lookups are needed + * **Confidential MPT transactions pay the confidential multiplier.** All five (`ConfidentialMPTSend`, `ConfidentialMPTConvert`, `ConfidentialMPTConvertBack`, `ConfidentialMPTMergeInbox`, `ConfidentialMPTClawback`) call `Transactor::calculateBaseFee` with `kConfidentialFeeMultiplier` = 9, i.e. ten base fees for a single-signed transaction, paying for the cryptographic proofs they carry. They were being autofilled at one base fee — a tenfold underpayment + * **Repeated `OnConnected` handler failures now back off** — the give-up branch added earlier is bounded by `MaxReconnectAttempts`, but only when `StopAfterMaxAttempts` is set. With it turned off there is no give-up at all, and the delay between retries never grew: this path tears the reconnect loop down and starts it again on every failure, `StopReconnectLoop` zeroes `_reconnectAttempts`, a fresh sequence zeroes it again, and `CalcBackoff` derives the delay from that counter alone. The client therefore repeated connect → handler failure → teardown at a constant `ReconnectBaseDelay` forever — a sustained connection load on exactly the node that cannot serve requests yet. `StartReconnectLoop` now takes the value to seed the counter with, and the handler-failure path seeds it from its own consecutive-failure count so the sequence keeps growing across failures. `TestRepeatedOnConnectedFailuresBackOff` pins it; reverting the fix makes that test show ~100 reconnects in 20s at a flat ~200ms interval * **`_reconnectCts` is `volatile`** — the reconnect loop compares it by reference to decide whether it still owns the reconnect state, while `StopReconnectLoop`, `StartReconnectLoop` and `RetireCurrentSessionAndReconnectAsync` write it from other threads. A stale read could let a retired loop run one more iteration or make the owning loop stand down early. The other cross-thread fields in that class were already `volatile` * **Lending guide corrected** — the `Loan Fields` table in `LendingProtocol-Guide` (both languages) listed four names the ledger object does not have: `Account` (the borrower is in `Borrower`), plus `Counterparty`, `PrincipalRequested` and `PaymentTotal`, which are fields of the **`LoanSet` transaction**. After `PrincipalRequested` was removed from `LOLoan` in this release the guide would have promised a property that no longer exists. Fixed, with a note pointing the three transaction fields at `LoanSet` diff --git a/Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs b/Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs index f57096d6..d6f9926f 100644 --- a/Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs +++ b/Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs @@ -248,6 +248,226 @@ await Helper.ThrowsExceptionAsync(async () => #endregion + #region ConfidentialMPT Fee Tests + + [TestMethod] + public async Task TestUCalculateFee_ConfidentialMPTSend_AppliesConfidentialMultiplier() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC); + var tx = new Dictionary + { + ["TransactionType"] = "ConfidentialMPTSend", + ["Account"] = "rTestAccount" + }; + + await client.CalculateFeePerTransactionType(tx); + + // rippled: Transactor::calculateBaseFee(view, tx, kConfidentialFeeMultiplier) + // = base * 1 + base * 9 = base * 10 = 120 + Assert.AreEqual("120", tx["Fee"]); + } + + [TestMethod] + public async Task TestUCalculateFee_ConfidentialMPTClawback_Multisig_AddsSignerFee() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC); + var tx = new Dictionary + { + ["TransactionType"] = "ConfidentialMPTClawback", + ["Account"] = "rTestAccount" + }; + + await client.CalculateFeePerTransactionType(tx, signersCount: 2); + + // base * (1 + 2 signers) + base * 9 = 36 + 108 = 144 + Assert.AreEqual("144", tx["Fee"]); + } + + #endregion + + #region LoanSet Fee Tests + + [TestMethod] + public async Task TestUCalculateFee_LoanSet_UsesCounterpartySignerListSize() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) + { + CounterpartySignerLists = CreateSignerList(3) + }; + var tx = CreateLoanSetTx(); + + await client.CalculateFeePerTransactionType(tx); + + // base * (1 + 3 counterparty signers) = 48 + Assert.AreEqual("48", tx["Fee"]); + Assert.AreEqual(1, client.AccountInfoCalls); + // A signer list set in the last ledger is not in `validated` yet; missing it would underpay. + Assert.AreEqual(LedgerIndexType.Current, client.LastAccountInfoRequest?.LedgerIndex?.LedgerIndexType); + } + + [TestMethod] + public async Task TestUCalculateFee_LoanSet_CounterpartyWithoutSignerList_ChargesOneSignature() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC); + var tx = CreateLoanSetTx(); + + await client.CalculateFeePerTransactionType(tx); + + // base * (1 + 1 counterparty signature) = 24 + Assert.AreEqual("24", tx["Fee"]); + } + + [TestMethod] + public async Task TestUCalculateFee_LoanSet_ExistingCounterpartySignature_CountsActualSigners() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC); + var tx = CreateLoanSetTx(); + tx["CounterpartySignature"] = new JsonObject + { + ["Signers"] = new JsonArray + { + new JsonObject { ["Signer"] = new JsonObject { ["Account"] = "rSigner1" } }, + new JsonObject { ["Signer"] = new JsonObject { ["Account"] = "rSigner2" } } + } + }; + + await client.CalculateFeePerTransactionType(tx); + + // Signature already present: count it instead of querying the counterparty. + // base * (1 + 2 signers) = 36 + Assert.AreEqual("36", tx["Fee"]); + Assert.AreEqual(0, client.AccountInfoCalls); + } + + #endregion + + #region LoanPay Fee Tests + + [TestMethod] + public async Task TestUCalculateFee_LoanPay_FullPaymentFlag_UsesBaseFee() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) { LoanEntry = CreateLoan(paymentRemaining: 50) }; + var tx = CreateLoanPayTx(amount: "10000"); + tx["Flags"] = (uint)LoanPayFlags.tfLoanFullPayment; + + await client.CalculateFeePerTransactionType(tx); + + Assert.AreEqual("12", tx["Fee"]); + Assert.AreEqual(0, client.LedgerEntryCalls); + } + + [TestMethod] + public async Task TestUCalculateFee_LoanPay_FewPaymentsRemaining_UsesBaseFee() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) { LoanEntry = CreateLoan(paymentRemaining: 5) }; + var tx = CreateLoanPayTx(amount: "10000"); + + await client.CalculateFeePerTransactionType(tx); + + // PaymentRemaining <= kLoanPaymentsPerFeeIncrement: no extra charge + Assert.AreEqual("12", tx["Fee"]); + } + + [TestMethod] + public async Task TestUCalculateFee_LoanPay_ManyPayments_ChargesPerFiveIncrements() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) { LoanEntry = CreateLoan(paymentRemaining: 50) }; + var tx = CreateLoanPayTx(amount: "1000"); // 1000 / 100 = 10 payments → ceil(10/5) = 2 increments + + await client.CalculateFeePerTransactionType(tx); + + Assert.AreEqual("24", tx["Fee"]); + // A loan created in the last ledger is not in `validated` yet; missing it would underpay. + Assert.AreEqual(LedgerIndexType.Current, client.LastLedgerEntryRequest?.LedgerIndex?.LedgerIndexType); + } + + [TestMethod] + public async Task TestUCalculateFee_LoanPay_FewPaymentsRemainingWithLargeAmount_StillChargesMaxIncrements() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) { LoanEntry = CreateLoan(paymentRemaining: 6) }; + var tx = CreateLoanPayTx(amount: "1000000"); + + await client.CalculateFeePerTransactionType(tx); + + // rippled reads PaymentRemaining only as the <= 5 short-circuit and never clamps the + // estimate by it, so an amount covering 100 regular payments costs the full 20 increments + // even with 6 payments left. Clamping here would underpay and hit telINSUF_FEE_P. + Assert.AreEqual("240", tx["Fee"]); + } + + [TestMethod] + public async Task TestUCalculateFee_LoanPay_PeriodicPaymentNearDecimalLimit_DoesNotOverflow() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) + { + LoanEntry = CreateLoan(paymentRemaining: 50, periodicPayment: "79000000000000000000000000000") + }; + var tx = CreateLoanPayTx(amount: "1000"); + + await client.CalculateFeePerTransactionType(tx); + + // The amount does not even cover one payment: one increment, and no arithmetic overflow. + Assert.AreEqual("12", tx["Fee"]); + } + + [TestMethod] + public async Task TestUCalculateFee_LoanPay_HugeAmount_CapsAtMaxIncrements() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) { LoanEntry = CreateLoan(paymentRemaining: 500) }; + var tx = CreateLoanPayTx(amount: "1000000"); // 10000 payments, capped at 100/5 = 20 increments + + await client.CalculateFeePerTransactionType(tx); + + Assert.AreEqual("240", tx["Fee"]); + } + + [TestMethod] + public async Task TestUCalculateFee_LoanPay_LoanNotFound_UsesBaseFee() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) { LedgerEntryThrows = true }; + var tx = CreateLoanPayTx(amount: "1000"); + + await client.CalculateFeePerTransactionType(tx); + + // rippled falls back to the normal cost and lets preclaim reject it + Assert.AreEqual("12", tx["Fee"]); + } + + [TestMethod] + public async Task TestUCalculateFee_LoanPay_Multisig_MultipliesWholeCost() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) { LoanEntry = CreateLoan(paymentRemaining: 50) }; + var tx = CreateLoanPayTx(amount: "1000"); + + await client.CalculateFeePerTransactionType(tx, signersCount: 1); + + // rippled multiplies the full Transactor cost: (base * (1 + 1)) * 2 increments = 48 + Assert.AreEqual("48", tx["Fee"]); + } + + [TestMethod] + public async Task TestUCalculateFee_LoanPay_IouAmount_RoundsPeriodicPaymentToLoanScale() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) + { + // PeriodicPayment 1.001 rounds up to 1.01 at scale -2, service fee 0.09 → 1.10 per payment + LoanEntry = CreateLoan(paymentRemaining: 50, periodicPayment: "1.001", loanServiceFee: "0.09", loanScale: -2) + }; + var tx = CreateLoanPayTx(amount: new Dictionary + { + ["currency"] = "USD", + ["issuer"] = "rIssuer", + ["value"] = "11" + }); + + await client.CalculateFeePerTransactionType(tx); + + // 11 / 1.10 = 10 payments → ceil(10/5) = 2 increments = 24 + Assert.AreEqual("24", tx["Fee"]); + } + + #endregion + #region MaxFee Tests [TestMethod] @@ -292,6 +512,47 @@ public async Task TestUCalculateFee_AccountDelete_NotCapped() ["Amount"] = "1000000" }; + private static Dictionary CreateLoanSetTx() => new() + { + ["TransactionType"] = "LoanSet", + ["Account"] = "rTestAccount", + ["LoanBrokerID"] = "0000000000000000000000000000000000000000000000000000000000000001", + ["Counterparty"] = "rCounterparty" + }; + + private static Dictionary CreateLoanPayTx(object amount) => new() + { + ["TransactionType"] = "LoanPay", + ["Account"] = "rTestAccount", + ["LoanID"] = "0000000000000000000000000000000000000000000000000000000000000002", + ["Amount"] = amount + }; + + private static LOLoan CreateLoan( + uint paymentRemaining, + string periodicPayment = "100", + string loanServiceFee = "0", + int loanScale = 0) => new() + { + PaymentRemaining = paymentRemaining, + PeriodicPayment = periodicPayment, + LoanServiceFee = loanServiceFee, + LoanScale = loanScale + }; + + private static LOSignerList[] CreateSignerList(int entries) + { + var list = new LOSignerList { SignerEntries = new List() }; + for (int i = 0; i < entries; i++) + { + list.SignerEntries.Add(new SignerEntryWrapper + { + SignerEntry = new SignerEntry { Account = $"rSigner{i}", SignerWeight = 1 } + }); + } + return new[] { list }; + } + #endregion } @@ -317,6 +578,21 @@ public FeeTestClient(string feeXrp, uint reserveInc, string maxFeeXRP = "5") public string maxFeeXRP { get; set; } public uint? networkID { get; set; } + /// Loan ledger object returned by , if any. + public LOLoan? LoanEntry { get; set; } + + /// Signer lists returned by , if any. + public LOSignerList[]? CounterpartySignerLists { get; set; } + + /// When true, fails as it would for a missing object. + public bool LedgerEntryThrows { get; set; } + + public int AccountInfoCalls { get; private set; } + public int LedgerEntryCalls { get; private set; } + + public AccountInfoRequest? LastAccountInfoRequest { get; private set; } + public LedgerEntryRequest? LastLedgerEntryRequest { get; private set; } + public Task ServerInfo(ServerInfoRequest request, CancellationToken cancellationToken = default) { var info = new ServerInfo @@ -372,7 +648,13 @@ public Task ServerState(ServerStateRequest request, CancellationTok public Task Unsubscribe(UnsubscribeRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task Ping(CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task Fee(CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task AccountInfo(AccountInfoRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task AccountInfo(AccountInfoRequest request, CancellationToken cancellationToken = default) + { + AccountInfoCalls++; + LastAccountInfoRequest = request; + return Task.FromResult(new AccountInfo { SignerLists = CounterpartySignerLists }); + } + public Task AccountOffers(AccountOffersRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task AccountCurrencies(AccountCurrenciesRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task AccountLines(AccountLinesRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); @@ -385,7 +667,15 @@ public Task ServerState(ServerStateRequest request, CancellationTok public Task LedgerClosed(LedgerClosedRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task LedgerCurrent(LedgerCurrentRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task LedgerData(LedgerDataRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); - public Task LedgerEntry(LedgerEntryRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task LedgerEntry(LedgerEntryRequest request, CancellationToken cancellationToken = default) + { + LedgerEntryCalls++; + LastLedgerEntryRequest = request; + if (LedgerEntryThrows) + throw new XrplException("entryNotFound"); + return Task.FromResult(new LedgerEntryResponse { Index = request.Index, Node = LoanEntry }); + } + public Task Submit(Dictionary tx, XrplWallet wallet, bool autoFill = true, bool failHard = false, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task Submit(ITransactionRequest tx, XrplWallet wallet, bool autoFill = true, bool failHard = false, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task Tx(TxRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); diff --git a/Xrpl/Sugar/Autofill.cs b/Xrpl/Sugar/Autofill.cs index f5e3bcdf..33d879a3 100644 --- a/Xrpl/Sugar/Autofill.cs +++ b/Xrpl/Sugar/Autofill.cs @@ -48,6 +48,26 @@ public static class AutofillSugar /// const int BATCH_BASE_FEE_MULTIPLIER = 3; + /// + /// rippled kConfidentialFeeMultiplier: extra base fee units charged to confidential MPT transactions. + /// + const int CONFIDENTIAL_FEE_MULTIPLIER = 9; + + /// + /// rippled lending::kLoanPaymentsPerFeeIncrement: loan payments covered by one base fee increment. + /// + const int LOAN_PAYMENTS_PER_FEE_INCREMENT = 5; + + /// + /// rippled lending::kLoanMaximumPaymentsPerTransaction: payments a single LoanPay ever processes. + /// + const int LOAN_MAX_PAYMENTS_PER_TRANSACTION = 100; + + /// + /// Upper bound on LoanPay fee increments, mirroring rippled kMaxFeeIncrements. + /// + const int LOAN_MAX_FEE_INCREMENTS = LOAN_MAX_PAYMENTS_PER_TRANSACTION / LOAN_PAYMENTS_PER_FEE_INCREMENT; + /// /// Autofills fields in a transaction. This will set `Sequence`, `Fee`, @@ -206,6 +226,14 @@ public static async Task CalculateFeePerTransactionType(this IXrplClient client, var sponsorSignerFee = baseFee * GetSponsorSignerCount(tx); calculatedFee += signerFee + sponsorSignerFee; + + // rippled LoanPay::calculateBaseFee multiplies the whole Transactor cost — + // signatures included — by one increment per kLoanPaymentsPerFeeIncrement payments. + if (transactionType == nameof(TransactionType.LoanPay)) + { + calculatedFee *= await GetLoanPayFeeIncrements(client, tx, cancellationToken); + } + BigInteger totalFee; if (!string.IsNullOrWhiteSpace(client.maxFeeXRP)) { @@ -234,14 +262,293 @@ private static async Task CalculateBaseFeeForType( { "EscrowFinish" when tx.TryGetValue("Fulfillment", out _) => CalculateEscrowFinishFee(tx, netFeeDrops), "Batch" => await CalculateBatchFee(client, tx, baseFee, cancellationToken), - // LoanSet requires CounterpartySignature (~150 bytes extra). - // Fee formula: baseFee * (1 + 1 counterparty signer) = baseFee * 2 - "LoanSet" => baseFee * 2, + nameof(TransactionType.LoanSet) => await CalculateLoanSetFee(client, tx, baseFee, cancellationToken), + _ when IsConfidentialMPTTx(transactionType) => baseFee * (1 + CONFIDENTIAL_FEE_MULTIPLIER), _ when IsReserveFeeTxNeed(tx) => await FetchReserveFee(client, cancellationToken), _ => baseFee }; } + /// + /// Confidential MPT transactions pay a flat extra multiplier for the cryptographic proofs + /// they carry (rippled Transactor::calculateBaseFee with kConfidentialFeeMultiplier). + /// + private static bool IsConfidentialMPTTx(string transactionType) + { + return transactionType + is nameof(TransactionType.ConfidentialMPTSend) + or nameof(TransactionType.ConfidentialMPTConvert) + or nameof(TransactionType.ConfidentialMPTConvertBack) + or nameof(TransactionType.ConfidentialMPTMergeInbox) + or nameof(TransactionType.ConfidentialMPTClawback); + } + + /// + /// Calculates fee for LoanSet, which charges one extra base fee per counterparty signature + /// (rippled LoanSet::calculateBaseFee counts CounterpartySignature.Signers, or the single + /// signature when present). + /// + /// + /// When the counterparty has not signed yet — the usual case during autofill — the signature + /// count is unknown, so the counterparty's signer list size is used to avoid underpaying. + /// + private static async Task CalculateLoanSetFee(IXrplClient client, Dictionary tx, BigInteger baseFee, CancellationToken cancellationToken = default) + { + int counterpartySigners = GetCounterpartySignerCount(tx); + if (counterpartySigners == 0) + { + counterpartySigners = await FetchCounterpartySignerCount(client, tx, cancellationToken); + } + + return baseFee * (1 + counterpartySigners); + } + + /// + /// Counts signatures already present in CounterpartySignature: every entry of a nested + /// Signers array, or one for a single signature. Returns 0 when the field is absent. + /// + private static int GetCounterpartySignerCount(Dictionary tx) + { + if (!tx.TryGetValue("CounterpartySignature", out var counterpartySignature) || counterpartySignature == null) + return 0; + + int nestedSigners = CountSigners(GetNestedField(counterpartySignature, "Signers")); + if (nestedSigners > 0) + return nestedSigners; + + return GetNestedField(counterpartySignature, "TxnSignature") != null ? 1 : 0; + } + + /// + /// Fetches the size of the counterparty's signer list, mirroring xrpl.js autofill: + /// the counterparty may multi-sign, so the fee has to cover every possible signer. + /// Falls back to a single signature when there is no signer list. + /// + private static async Task FetchCounterpartySignerCount(IXrplClient client, Dictionary tx, CancellationToken cancellationToken = default) + { + if (!tx.TryGetValue("Counterparty", out var counterparty) || counterparty is not string account || string.IsNullOrWhiteSpace(account)) + return 1; + + // Current, not validated: a signer list set in the last ledger has not been validated + // yet, and missing it would underpay the fee. + AccountInfoRequest request = new AccountInfoRequest(account) + { + LedgerIndex = new LedgerIndex(LedgerIndexType.Current), + SignerLists = true, + }; + + try + { + AccountInfo data = await client.AccountInfo(request, cancellationToken); + int? entries = data?.SignerLists?.Length > 0 ? data.SignerLists[0].SignerEntries?.Count : null; + return entries is > 0 ? entries.Value : 1; + } + catch (Exception) + { + // The counterparty account may not exist yet; preclaim rejects the transaction anyway. + return 1; + } + } + + /// + /// Number of base fee increments a LoanPay transaction is charged: one per + /// kLoanPaymentsPerFeeIncrement payments the transaction is expected to process, + /// capped at kLoanMaximumPaymentsPerTransaction / kLoanPaymentsPerFeeIncrement. + /// Returns 1 whenever rippled falls back to the normal cost. + /// + private static async Task GetLoanPayFeeIncrements(IXrplClient client, Dictionary tx, CancellationToken cancellationToken = default) + { + uint flags = ParseTransactionFlags(tx); + bool isFullPayment = (flags & (uint)LoanPayFlags.tfLoanFullPayment) != 0; + bool isLatePayment = (flags & (uint)LoanPayFlags.tfLoanLatePayment) != 0; + + // A full or late payment performs one set of calculations regardless of the amount. + if (isFullPayment || isLatePayment) + return BigInteger.One; + + if (!tx.TryGetValue("LoanID", out var loanId) || loanId is not string id || string.IsNullOrWhiteSpace(id)) + return BigInteger.One; + + if (!TryGetLoanPayAmount(tx, out decimal amount, out bool integralAsset) || amount <= 0) + return BigInteger.One; + + LOLoan loan = await FetchLoan(client, id, cancellationToken); + if (loan == null) + return BigInteger.One; + + // Fewer payments left than one increment covers: no extra work to charge for. + if (loan.PaymentRemaining is null or <= LOAN_PAYMENTS_PER_FEE_INCREMENT) + return BigInteger.One; + + if (!TryParseNumber(loan.PeriodicPayment, out decimal periodicPayment) || periodicPayment <= 0) + return BigInteger.One; + + TryParseNumber(loan.LoanServiceFee, out decimal serviceFee); + decimal regularPayment = RoundPeriodicPayment(periodicPayment, integralAsset, loan.LoanScale ?? 0) + serviceFee; + if (regularPayment <= 0) + return BigInteger.One; + + // The payment handler never processes more than kLoanMaximumPaymentsPerTransaction payments. + // Divided rather than multiplied: a periodic payment near decimal.MaxValue overflows when + // scaled up, while dividing the amount by a constant never can. + if (amount / LOAN_MAX_PAYMENTS_PER_TRANSACTION >= regularPayment) + return LOAN_MAX_FEE_INCREMENTS; + + // Overpayments do about as much work as a full payment, so they round up. + bool isOverpayment = (flags & (uint)LoanPayFlags.tfLoanOverpayment) != 0; + decimal paymentEstimate = amount / regularPayment; + decimal payments = isOverpayment ? Math.Ceiling(paymentEstimate) : Math.Floor(paymentEstimate); + + decimal increments = Math.Ceiling(payments / LOAN_PAYMENTS_PER_FEE_INCREMENT); + if (increments < 1) + return BigInteger.One; + + return increments > LOAN_MAX_FEE_INCREMENTS ? LOAN_MAX_FEE_INCREMENTS : new BigInteger(increments); + } + + /// + /// Reads the Loan ledger object referenced by LoanPay. Returns null when it cannot be + /// retrieved — rippled behaves the same way and lets preclaim report the error. + /// + private static async Task FetchLoan(IXrplClient client, string loanId, CancellationToken cancellationToken = default) + { + try + { + // Current, not validated: a loan created in the last ledger has not been validated + // yet, and treating it as missing would underpay the fee. + LedgerEntryRequest request = new LedgerEntryRequest + { + Index = loanId, + LedgerIndex = new LedgerIndex(LedgerIndexType.Current), + }; + LedgerEntryResponse response = await client.LedgerEntry(request, cancellationToken); + return response?.Node as LOLoan; + } + catch (Exception) + { + return null; + } + } + + /// + /// rippled roundPeriodicPayment: integral assets (XRP, MPT) round up to whole units, + /// IOUs round up to a multiple of 10^scale. + /// + private static decimal RoundPeriodicPayment(decimal periodicPayment, bool integralAsset, int scale) + { + if (integralAsset) + return Math.Ceiling(periodicPayment); + + // Outside this range the step cannot be represented as a decimal; leave the value alone. + if (scale is < -28 or > 28) + return periodicPayment; + + decimal step = scale >= 0 + ? Pow10(scale) + : 1m / Pow10(-scale); + + return Math.Ceiling(periodicPayment / step) * step; + } + + private static decimal Pow10(int exponent) + { + decimal result = 1m; + for (int i = 0; i < exponent; i++) + { + result *= 10m; + } + return result; + } + + /// + /// Extracts the LoanPay amount and whether its asset is integral (XRP drops or MPT units, + /// which rippled rounds to whole units) as opposed to an IOU. + /// + private static bool TryGetLoanPayAmount(Dictionary tx, out decimal amount, out bool integralAsset) + { + amount = 0m; + integralAsset = true; + + if (!tx.TryGetValue("Amount", out var rawAmount) || rawAmount == null) + return false; + + if (rawAmount is string xrpDrops) + return TryParseNumber(xrpDrops, out amount); + + object value = GetNestedField(rawAmount, "value"); + if (value == null) + return false; + + // MPT amounts are integral like XRP; issued currencies carry decimals. + integralAsset = GetNestedField(rawAmount, "mpt_issuance_id") != null; + return TryParseNumber(ToPlainValue(value), out amount); + } + + private static uint ParseTransactionFlags(Dictionary tx) + { + if (!tx.TryGetValue("Flags", out var flags) || flags == null) + return 0; + + return flags switch + { + uint u => u, + int i when i >= 0 => (uint)i, + long l when l is >= 0 and <= uint.MaxValue => (uint)l, + LoanPayFlags f => (uint)f, + JsonValue jv when jv.TryGetValue(out uint u) => u, + JsonElement je when je.ValueKind == JsonValueKind.Number && je.TryGetUInt32(out uint u) => u, + string s when uint.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out uint u) => u, + _ => 0 + }; + } + + /// + /// Parses a rippled Number field, which is serialized as a decimal string. + /// + private static bool TryParseNumber(object value, out decimal result) + { + result = 0m; + return value != null + && decimal.TryParse( + value as string ?? value.ToString(), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out result); + } + + private static object GetNestedField(object container, string fieldName) + { + return container switch + { + Dictionary dict => dict.TryGetValue(fieldName, out var value) ? value : null, + JsonObject jo => jo.TryGetPropertyValue(fieldName, out var node) ? node : null, + JsonElement je when je.ValueKind == JsonValueKind.Object && je.TryGetProperty(fieldName, out var prop) => prop, + _ => null + }; + } + + private static object ToPlainValue(object value) + { + return value switch + { + JsonNode node => node.ToString(), + JsonElement je => je.ValueKind == JsonValueKind.String ? je.GetString() : je.ToString(), + _ => value + }; + } + + private static int CountSigners(object signers) + { + return signers switch + { + JsonArray ja => ja.Count, + JsonElement je when je.ValueKind == JsonValueKind.Array => je.GetArrayLength(), + ICollection collection => collection.Count, + IEnumerable enumerable => enumerable.Count(), + _ => 0 + }; + } + /// /// Calculates fee for EscrowFinish with Fulfillment. /// Formula: 10 drops × (33 + (Fulfillment size in bytes / 16)) @@ -297,20 +604,7 @@ private static int GetSponsorSignerCount(Dictionary tx) if (!tx.TryGetValue("SponsorSignature", out var sponsorSignature) || sponsorSignature == null) return 0; - object signers = sponsorSignature switch - { - Dictionary dict => dict.TryGetValue("Signers", out var s) ? s : null, - JsonObject jo => jo.TryGetPropertyValue("Signers", out var node) ? (object)node : null, - _ => null - }; - - return signers switch - { - JsonArray ja => ja.Count, - ICollection collection => collection.Count, - IEnumerable enumerable => enumerable.Count(), - _ => 0 - }; + return CountSigners(GetNestedField(sponsorSignature, "Signers")); } private static bool TryGetInnerFieldsAsDict(object item, out Dictionary dict) From 1567505e6b5ed9ff4cfd0751940a00ca45216807 Mon Sep 17 00:00:00 2001 From: Aleksandr <44946855+Platonenkov@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:30:20 -0300 Subject: [PATCH 10/14] =?UTF-8?q?feat(models)!:=20=D1=81=D1=85=D0=B5=D0=BC?= =?UTF-8?q?=D0=B0=20Sponsor=20=D0=B8=20DynamicMPT=20=D0=BF=D0=BE=20rippled?= =?UTF-8?q?=203.3.0=20+=20=D1=81=D1=82=D0=BE=D1=80=D0=BE=D0=B6=20=D0=BF?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=20nightly=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: bump standalone stand to rippled 3.3.0 * feat(models)!: ImmutableFlags и дельты SponsorshipSet по схеме rippled 3.3.0 Стенд перешёл на релиз 3.3.0, что включает Sponsor, DynamicMPT, BatchV1_1, PermissionDelegationV1_1 и ConfidentialTransfer на генезисе — 44 теста, ранее скипавшихся через AmendmentGuard, начали выполняться, и 17 из них упали: обе фичи были реализованы против nightly-сборки 3.3.0~b1 от 11.07.2026, а до релиза upstream поменял их схему. DynamicMPT: - sfMutableFlags переименовано в sfImmutableFlags (тот же UInt32 nth 53, те же значения битов) с инверсией смысла: установленный бит означает «заморожено», а не «разрешено менять». Выпуск без поля теперь полностью изменяемый — противоположность прежнему поведению - MPTokenIssuanceCreateMutableFlags и MPTokenIssuanceSetMutableFlags заменены общим MPTokenIssuanceImmutableFlags (tif* = lsif*) - включение возможности переехало из отдельного поля в флаги транзакции: MPTokenIssuanceSetFlags пополнен tfMPTSet* (0x04–0x100) Sponsor: - SponsorshipSet принимает FeeAmountDelta (Amount 34) и RemainingOwnerCountDelta (Int32 2) вместо абсолютных FeeAmount/RemainingOwnerCount, которые остались полями только у объекта Sponsorship. Старые поля нода отвергает на разборе: STObject::applyTemplate → «Field 'FeeAmount' found in disallowed location» - клиентская валидация повторяет preflight: дельты ненулевые, FeeAmountDelta в XRP, при tfDeleteObject модификационные поля запрещены - добавлен Common.TryGetInt32 для знаковой дельты Фикстуры гвардов перепиныны на тег 3.3.0 (transactions.macro, LedgerFormats.h); ledger_entries.macro остаётся на develop из-за sfLEVersion. RippledLedgerFlags научился читать lsif*-константы, которые в 3.3.0 объявлены вне LEDGER_OBJECT, и падает, если не нашёл ни одной. Пин nightly-стенда сдвинут на 3.4.0~b0+202608111815.26cc683e, конфиг перегенерирован с того же ref: Definitions Watch поднимает стенд именно из него, поэтому устаревший пин делал еженедельный мониторинг слепым к этим двум переименованиям. * ci: еженедельный сторож пина nightly-стенда definitions-watch поднимает стенд из ARG XRPLD_VERSION, поэтому устаревший пин сужает еженедельную проверку до состояния rippled на момент последнего касания пина: оба переименования 3.3.0 месяц просидели за пином от 11.07. Снять пин нельзя — формат таймстампа nightly-сборок сократился с 14 до 12 цифр, и порядок версий Debian ставит старые сборки выше новых. - .ci-config/bump-nightly-pin.sh: берёт свежую сборку xrpld из nightly-канала, переписывает ARG XRPLD_VERSION и перегенерирует rippled.batchv11.cfg по develop-коммиту, зашитому в саму версию — конфиг и бинарник не могут разойтись. Режим --check только отчитывается: текущий пин, свежая сборка, возраст пина - .github/workflows/nightly-pin-watch.yml: раз в неделю; бампает, когда пин старше MAX_PIN_AGE_DAYS (21), поднимает стенд на новом пине, требует включённого на генезисе AMM-сентинела и прикладывает к PR дифф definitions против новой сборки. Учётки, идемпотентность и запасной путь через tracking issue — как в release-watch * fix(ci): валидное имя ветки в nightly-pin-watch и правка changelog Замечания CodeRabbit к PR #83. - имя ветки собиралось из версии xrpld как есть, а `~` git в ref не пускает: `git checkout -b` упал бы и PR с бампом не создавался. Версия чистится по классу допустимых символов, а не по одному символу текущего формата, плюс проверка через git check-ref-format перед использованием - в секции 10.11.0.0 CHANGES.md остались пункты, описывающие MutableFlags и tmfMPTSet* как актуальный API, хотя тот же релиз их и заменяет. Приведены к ImmutableFlags/tfMPTSet*; более ранние секции не тронуты — они описывают состояние API на момент своего релиза --------- Co-authored-by: github-merge-queue --- .ci-config/Dockerfile.nightly | 2 +- .ci-config/bump-nightly-pin.sh | 121 +++++++ .ci-config/docker-compose.ci.yml | 2 +- .ci-config/rippled.batchv11.cfg | 4 +- .ci-config/rippled.cfg | 22 +- .github/workflows/definitions-watch.yml | 3 + .github/workflows/nightly-pin-watch.yml | 308 ++++++++++++++++++ .github/workflows/release-watch.yml | 7 +- .../Enums/Field.Amount.Generated.cs | 1 + .../Enums/Field.Int32.Generated.cs | 1 + .../Enums/Field.Uint32.Generated.cs | 2 +- Base/Xrpl.BinaryCodec/Enums/definitions.json | 22 +- CHANGES.md | 29 +- CLAUDE.md | 4 +- DocFx/ConfidentialMPT-Guide.md | 6 +- DocFx/ConfidentialMPT-Guide.ru.md | 6 +- DocFx/Sponsorship-Guide.md | 15 +- DocFx/Sponsorship-Guide.ru.md | 15 +- Tests/Xrpl.Tests/Fixtures/LedgerFormats.h | 22 +- Tests/Xrpl.Tests/Fixtures/LedgerFormats.h.ref | 10 +- .../Xrpl.Tests/Fixtures/ledger_entries.macro | 2 +- .../Fixtures/ledger_entries.macro.ref | 8 +- Tests/Xrpl.Tests/Fixtures/transactions.macro | 10 +- .../Fixtures/transactions.macro.ref | 9 +- .../transactions/TestIBatchSponsorship.cs | 4 +- .../transactions/TestIDynamicMPT.cs | 115 ++++--- .../transactions/TestISponsorship.cs | 46 ++- .../TestISponsorshipSigningMatrix.cs | 2 +- Tests/Xrpl.Tests/Models/RippledLedgerFlags.cs | 35 ++ .../Xrpl.Tests/Models/TestUConfidentialMPT.cs | 6 +- .../Models/TestULedgerFlagsConformance.cs | 11 +- .../Models/TestUProtocolCompleteness.cs | 33 +- Xrpl/Models/Ledger/LOMPTokenIssuance.cs | 6 +- Xrpl/Models/Transactions/Common.cs | 17 + .../Transactions/MPTokenIssuanceCreate.cs | 58 +--- .../MPTokenIssuanceImmutableFlags.cs | 52 +++ .../Models/Transactions/MPTokenIssuanceSet.cs | 90 +++-- Xrpl/Models/Transactions/SponsorshipSet.cs | 65 +++- Xrpl/Models/Transactions/TxFormat.cs | 8 +- 39 files changed, 922 insertions(+), 257 deletions(-) create mode 100755 .ci-config/bump-nightly-pin.sh create mode 100644 .github/workflows/nightly-pin-watch.yml create mode 100644 Xrpl/Models/Transactions/MPTokenIssuanceImmutableFlags.cs diff --git a/.ci-config/Dockerfile.nightly b/.ci-config/Dockerfile.nightly index a87c7af0..4a0a0773 100644 --- a/.ci-config/Dockerfile.nightly +++ b/.ci-config/Dockerfile.nightly @@ -10,7 +10,7 @@ RUN install -m 0755 -d /usr/share/keyrings \ # rippled was renamed to xrpld on the develop branch; the nightly channel publishes it as the "xrpld" package. # The version must be pinned: the timestamp format changed from 14 to 12 digits mid-2026, # so Debian version ordering ranks old 14-digit builds above the newer 12-digit ones. -ARG XRPLD_VERSION=3.3.0~b1+202607110018.8306ac77-1 +ARG XRPLD_VERSION=3.4.0~b0+202608111815.26cc683e-1 RUN echo "deb [signed-by=/usr/share/keyrings/ripple-key.gpg] https://repos.ripple.com/repos/rippled-deb jammy nightly" > /etc/apt/sources.list.d/ripple.list \ && apt-get update \ && apt-get install -y --no-install-recommends --allow-downgrades "xrpld=${XRPLD_VERSION}" \ diff --git a/.ci-config/bump-nightly-pin.sh b/.ci-config/bump-nightly-pin.sh new file mode 100755 index 00000000..975cc95f --- /dev/null +++ b/.ci-config/bump-nightly-pin.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Move the nightly stand to the newest xrpld build published in the nightly apt +# channel: rewrite ARG XRPLD_VERSION in Dockerfile.nightly and regenerate +# rippled.batchv11.cfg from the matching develop commit. +# +# Why this needs to happen regularly, not only when a new amendment is wanted: +# definitions-watch raises its stand from docker-compose.batchv11.yml, i.e. from +# THIS pin. While the pin is stale the weekly monitor diffs definitions.json +# against a build older than the one CI runs and reports "in sync" about the +# past — which is how rippled 3.3.0 renaming sfMutableFlags to sfImmutableFlags +# and moving SponsorshipSet onto delta fields reached the CI bump PR unnoticed. +# +# The generated config MUST come from the commit the pinned binary was built +# from: a newer ref can emit feature names the binary does not know, and rippled +# rejects unknown names in config at startup. The commit is taken from the +# version string itself, so the two cannot drift apart. +# +# Usage: +# .ci-config/bump-nightly-pin.sh # bump to the newest build +# .ci-config/bump-nightly-pin.sh --check # report only, change nothing +# +# Outputs (stdout, and $GITHUB_OUTPUT when set): +# old_version, new_version, new_ref, age_days (age of the CURRENT pin) +# +# Exit codes: 0 done or already current (see `bumped`), 1 error. + +set -euo pipefail + +PACKAGES_URL="https://repos.ripple.com/repos/rippled-deb/dists/jammy/nightly/binary-amd64/Packages" +DIR="$(cd "$(dirname "$0")" && pwd)" +DOCKERFILE="$DIR/Dockerfile.nightly" +CFG="$DIR/rippled.batchv11.cfg" + +check_only=false +if [ "${1:-}" = "--check" ]; then + check_only=true +elif [ $# -gt 0 ]; then + echo "usage: $(basename "$0") [--check]" >&2 + exit 1 +fi + +for f in "$DOCKERFILE" "$CFG"; do + [ -f "$f" ] || { echo "error: not found: $f" >&2; exit 1; } +done + +old_version=$(sed -n -E 's/^ARG XRPLD_VERSION=(.+)$/\1/p' "$DOCKERFILE") +if [ -z "$old_version" ]; then + echo "error: could not read ARG XRPLD_VERSION from $DOCKERFILE" >&2 + exit 1 +fi + +packages=$(curl -sf --max-time 60 "$PACKAGES_URL") || { + echo "error: could not fetch $PACKAGES_URL" >&2 + exit 1 +} + +# Version strings look like 3.4.0~b0+202608111815.26cc683e-1: upstream version, +# a build timestamp and the develop commit it was built from. The timestamp +# format shrank from 14 digits (YYYYMMDDHHMMSS) to 12 (YYYYMMDDHHMM) mid-2026, +# which is why plain version sorting ranks old builds above new ones and why the +# pin exists at all. Truncating both to YYYYMMDDHHMM makes them comparable. +newest=$(printf '%s\n' "$packages" \ + | awk '/^Package: xrpld$/ { p = 1; next } /^Version: / { if (p) print $2; p = 0 }' \ + | while IFS= read -r v; do + ts=$(printf '%s' "$v" | sed -n -E 's/.*\+([0-9]{12,14})\..*/\1/p') + [ -n "$ts" ] && printf '%s %s\n' "${ts:0:12}" "$v" + done \ + | LC_ALL=C sort -k1,1n | tail -1) + +if [ -z "$newest" ]; then + echo "error: no parseable xrpld versions in the nightly Packages index" >&2 + exit 1 +fi + +new_version=${newest#* } +new_ts=${newest%% *} +new_ref=$(printf '%s' "$new_version" | sed -n -E 's/.*\+[0-9]{12,14}\.([0-9a-f]+).*/\1/p') +if [ -z "$new_ref" ]; then + echo "error: could not extract the develop commit from '$new_version'" >&2 + exit 1 +fi + +old_ts=$(printf '%s' "$old_version" | sed -n -E 's/.*\+([0-9]{12,14})\..*/\1/p') +old_ts=${old_ts:0:12} +age_days="" +if [ -n "$old_ts" ]; then + old_epoch=$(date -u -d "${old_ts:0:8} ${old_ts:8:2}:${old_ts:10:2}" +%s 2>/dev/null || echo "") + if [ -n "$old_epoch" ]; then + age_days=$(( ( $(date -u +%s) - old_epoch ) / 86400 )) + fi +fi + +bumped=false +if [ "$old_version" = "$new_version" ]; then + echo "Nightly pin is already the newest build ($old_version)." +elif [ "$check_only" = true ]; then + echo "Nightly pin $old_version is behind $new_version (build ${new_ts}); run without --check to bump." +else + # Match the whole value so a partially-rewritten pin cannot survive. + sed -i -E "s#^ARG XRPLD_VERSION=.+\$#ARG XRPLD_VERSION=${new_version}#" "$DOCKERFILE" + written=$(sed -n -E 's/^ARG XRPLD_VERSION=(.+)$/\1/p' "$DOCKERFILE") + if [ "$written" != "$new_version" ]; then + echo "error: rewriting ARG XRPLD_VERSION failed (file now holds '$written')" >&2 + exit 1 + fi + bash "$DIR/generate-amendments.sh" "$new_ref" "$CFG" + bumped=true + echo "Nightly pin bumped: $old_version -> $new_version (ref $new_ref)." +fi + +[ -n "$age_days" ] && echo "Current pin age before this run: ${age_days}d." + +if [ -n "${GITHUB_OUTPUT:-}" ]; then + { + echo "old_version=$old_version" + echo "new_version=$new_version" + echo "new_ref=$new_ref" + echo "age_days=${age_days:-unknown}" + echo "bumped=$bumped" + } >> "$GITHUB_OUTPUT" +fi diff --git a/.ci-config/docker-compose.ci.yml b/.ci-config/docker-compose.ci.yml index edd8e90a..81eef46f 100644 --- a/.ci-config/docker-compose.ci.yml +++ b/.ci-config/docker-compose.ci.yml @@ -1,6 +1,6 @@ services: xrpld: - image: xrpllabsofficial/xrpld:3.2.1 + image: xrpllabsofficial/xrpld:3.3.0 container_name: rippled-service command: ["-a", "--start"] ports: diff --git a/.ci-config/rippled.batchv11.cfg b/.ci-config/rippled.batchv11.cfg index c68c941f..f2999f3c 100644 --- a/.ci-config/rippled.batchv11.cfg +++ b/.ci-config/rippled.batchv11.cfg @@ -115,13 +115,13 @@ TokenEscrow XChainBridge XRPFees fixAMMClawbackRounding -fixAMMOverflowOffer fixAMMv1_1 fixAMMv1_2 fixAMMv1_3 fixCleanup3_1_3 fixCleanup3_2_0 fixCleanup3_3_0 +fixCleanup3_4_0 fixDirectoryLimit fixEmptyDID fixEnforceNFTokenTrustline @@ -175,13 +175,13 @@ BE1F90581635DBCEBFC4678C4B54FEDDC1A17B50FD02CFE765A4132A342126AC Sponsor C98D98EE9616ACD36E81FDEB8D41D349BF5F1B41DD64A0ABC1FE9AA5EA267E9C XChainBridge 93E516234E35E08CA689FA33A6D38E103881F8DCB53023F728C307AA89D515A7 XRPFees 5E9586DB3D765B4C5794658FB6BB385071E9838DF4016027E6E26820C8526724 fixAMMClawbackRounding -12523DF04B553A0B1AD74F42DDB741DE8DC06A03FC089A0EF197E2A87F1D8107 fixAMMOverflowOffer 35291ADD2D79EB6991343BDA0912269C817D0F094B02226C1C14AD2858962ED4 fixAMMv1_1 1E7ED950F2F13C4F8E2A54103B74D57D5D298FFDBD005936164EE9E6484C438C fixAMMv1_2 7CA70A7674A26FA517412858659EBC7EDEEF7D2D608824464E6FDEFD06854E14 fixAMMv1_3 303ACB16CF8DBD3B5C34F131A9D19A7DE01AE05F480A8A682B869D1B4AAC8CFC fixCleanup3_1_3 21B8D2F76F68E11E9C077A43BBBC394136E9987E99DDB73966DD68419467E431 fixCleanup3_2_0 3298D47E1F3A8A24FECAA30F699B8FE1DD234E072834BA099AD8180FFCE0FEC4 fixCleanup3_3_0 +98433DD001A5737F773D74F8CA2A25A065089C73B2E611C760BAF369E4FECA76 fixCleanup3_4_0 41765F664A8D67FF03DDB1C1A893DE6273690BA340A6C2B07C8D29D0DD013D3A fixDirectoryLimit 755C971C29971C9F20C6F080F2ED96F87884E40AD19554A5EBECDCEC8A1F77FE fixEmptyDID 763C37B352BE8C7A04E810F8E462644C45AFEAD624BF3894A08E5C917CF9FF39 fixEnforceNFTokenTrustline diff --git a/.ci-config/rippled.cfg b/.ci-config/rippled.cfg index 55cd3f06..954df328 100644 --- a/.ci-config/rippled.cfg +++ b/.ci-config/rippled.cfg @@ -103,19 +103,23 @@ validators.txt # retired/unknown feature names at startup. AMM AMMClawback -Clawback +BatchV1_1 +ConfidentialTransfer Credentials DID DeepFreeze +DynamicMPT DynamicNFT LendingProtocol MPTokensV1 MPTokensV2 NFTokenMintOffer +PermissionDelegationV1_1 PermissionedDEX PermissionedDomains PriceOracle SingleAssetVault +Sponsor TokenEscrow XChainBridge XRPFees @@ -126,27 +130,24 @@ fixAMMv1_2 fixAMMv1_3 fixCleanup3_1_3 fixCleanup3_2_0 +fixCleanup3_3_0 fixDirectoryLimit -fixDisallowIncomingV1 fixEmptyDID fixEnforceNFTokenTrustline fixEnforceNFTokenTrustlineV2 fixFillOrKill fixFrozenLPTokenTransfer fixIncludeKeyletFields -fixInnerObjTemplate fixInnerObjTemplate2 fixInvalidTxFlags fixMPTDeliveredAmount fixNFTokenPageLinks -fixNFTokenReserve fixPayChanCancelAfter fixPreviousTxnID fixPriceOracleOrder fixReducedOffersV2 fixRemoveNFTokenAutoTrustLine fixTokenEscrowV1 -fixUniversalNumber fixXChainRewardRounding [network_id] @@ -162,18 +163,22 @@ fixXChainRewardRounding # Supported::No ones (e.g. MPTokensV2) live in [features] as Rules presets. 8CC0774A3BF66D1D22E76BBDA8E8A232E6B6313834301B3B23E8601196AE6455 AMM 726F944886BCDF7433203787E93DD9AA87FAB74DFE3AF4785BA03BEFC97ADA1F AMMClawback -56B241D7A43D40354D02A9DC4C8DF5C7A1F930D92A9035C4E12291B3CA3E1C2B Clawback +9F287AED3CDB50A7BD1ACEC24296A30C9B5230CCD136219317AC790E3B884377 BatchV1_1 +2110E4A19966E2EF517C0A8C56A5F35099D7665B0BB89D7B126B30D50B86AAD5 ConfidentialTransfer 1CB67D082CF7D9102412D34258CEDB400E659352D3B207348889297A6D90F5EF Credentials DB432C3A09D9D5DFC7859F39AE5FF767ABC59AED0A9FB441E83B814D8946C109 DID DAF3A6EB04FA5DC51E8E4F23E9B7022B693EFA636F23F22664746C77B5786B23 DeepFreeze +58E92F338758479C06084E1B6BA366BAD8F75E5329A7F0EEAFFFDA51E5106B7F DynamicMPT C1CE18F2A268E6A849C27B3DE485006771B4C01B2FCEC4F18356FE92ECD6BB74 DynamicNFT 565B90CA1AB2B9D42208ED10884188C64F9E19083DECB9634AAF06EB03299509 LendingProtocol 950AE2EA4654E47F04AA8739C0B214E242097E802FD372D24047A89AB1F5EC38 MPTokensV1 EE3CF852F0506782D05E65D49E5DCC3D16D50898CD1B646BAE274863401CC3CE NFTokenMintOffer +0F48FF561C709540328F31F1C97FD512ACC8B4E42138A161CB0E21ECA292540B PermissionDelegationV1_1 677E401A423E3708363A36BA8B3A7D019D21AC5ABD00387BDBEA6BDE4C91247E PermissionedDEX A730EB18A9D4BB52502C898589558B4CCEB4BE10044500EE5581137A2E80E849 PermissionedDomains 96FD2F293A519AE1DB6F8BED23E4AD9119342DA7CB6BAFD00953D16C54205D8B PriceOracle 81BD2619B6B3C8625AC5D0BC01DE17F06C3F0AB95C7C87C93715B87A4FD240D8 SingleAssetVault +BE1F90581635DBCEBFC4678C4B54FEDDC1A17B50FD02CFE765A4132A342126AC Sponsor 138B968F25822EFBF54C00F97031221C47B1EAB8321D93C7C2AEAF85F04EC5DF TokenEscrow C98D98EE9616ACD36E81FDEB8D41D349BF5F1B41DD64A0ABC1FE9AA5EA267E9C XChainBridge 93E516234E35E08CA689FA33A6D38E103881F8DCB53023F728C307AA89D515A7 XRPFees @@ -184,26 +189,23 @@ C98D98EE9616ACD36E81FDEB8D41D349BF5F1B41DD64A0ABC1FE9AA5EA267E9C XChainBridge 7CA70A7674A26FA517412858659EBC7EDEEF7D2D608824464E6FDEFD06854E14 fixAMMv1_3 303ACB16CF8DBD3B5C34F131A9D19A7DE01AE05F480A8A682B869D1B4AAC8CFC fixCleanup3_1_3 21B8D2F76F68E11E9C077A43BBBC394136E9987E99DDB73966DD68419467E431 fixCleanup3_2_0 +3298D47E1F3A8A24FECAA30F699B8FE1DD234E072834BA099AD8180FFCE0FEC4 fixCleanup3_3_0 41765F664A8D67FF03DDB1C1A893DE6273690BA340A6C2B07C8D29D0DD013D3A fixDirectoryLimit -15D61F0C6DB6A2F86BCF96F1E2444FEC54E705923339EC175BD3E517C8B3FF91 fixDisallowIncomingV1 755C971C29971C9F20C6F080F2ED96F87884E40AD19554A5EBECDCEC8A1F77FE fixEmptyDID 763C37B352BE8C7A04E810F8E462644C45AFEAD624BF3894A08E5C917CF9FF39 fixEnforceNFTokenTrustline B32752F7DCC41FB86534118FC4EEC8F56E7BD0A7DB60FD73F93F257233C08E3A fixEnforceNFTokenTrustlineV2 3318EA0CF0755AF15DAC19F2B5C5BCBFF4B78BDD57609ACCAABE2C41309B051A fixFillOrKill 83FD6594FF83C1D105BD2B41D7E242D86ECB4A8220BD9AF4DA35CB0F69E39B2A fixFrozenLPTokenTransfer 6143A27B71F7DAF9330ECA7C5EC3D54C8083A4FDEF7016737EEC06AB61E82EE0 fixIncludeKeyletFields -C393B3AEEBF575E475F0C60D5E4241B2070CC4D0EB6C4846B1A07508FAEFC485 fixInnerObjTemplate 9196110C23EA879B4229E51C286180C7D02166DA712559F634372F5264D0EC59 fixInnerObjTemplate2 8EC4304A06AF03BE953EA6EDA494864F6F3F30AA002BABA35869FBB8C6AE5D52 fixInvalidTxFlags AB8D932A5F338903FE5BCBD80B611FFED70839ABA3170E9CE01D947C0EDEDCF2 fixMPTDeliveredAmount C7981B764EC4439123A86CC7CCBA436E9B3FF73B3F10A0AE51882E404522FC41 fixNFTokenPageLinks -03BDC0099C4E14163ADA272C1B6F6FABB448CC3E51F522F978041E4B57D9158C fixNFTokenReserve D3456A862DC07E382827981CA02E21946E641877F19B8889031CC57FDCAC83E2 fixPayChanCancelAfter 7BB62DC13EC72B775091E9C71BF8CF97E122647693B50C5E87A80DFD6FCFAC50 fixPreviousTxnID FF2D1E13CF6D22427111B967BD504917F63A900CECD320D6FD3AC9FA90344631 fixPriceOracleOrder 31E0DA76FB8FB527CADCDF0E61CB9C94120966328EFA9DCA202135BAF319C0BA fixReducedOffersV2 DF8B4536989BDACE3F934F29423848B9F1D76D09BE6A1FCFE7E7F06AA26ABEAD fixRemoveNFTokenAutoTrustLine 32B8614321F7E070419115ABEAB1742EA20F3E3AF34432B5E2F474F8083260DC fixTokenEscrowV1 -2E2FB9CF8A44EB80F4694D38AADAE9B8B7ADAFD2F092E10068E61C98C4F092B0 fixUniversalNumber 2BF037D90E1B676B17592A8AF55E88DB465398B4B597AE46EECEE1399AB05699 fixXChainRewardRounding diff --git a/.github/workflows/definitions-watch.yml b/.github/workflows/definitions-watch.yml index a878138d..78de9448 100644 --- a/.github/workflows/definitions-watch.yml +++ b/.github/workflows/definitions-watch.yml @@ -7,6 +7,9 @@ name: Definitions Watch # # "develop" here = the pinned xrpld version in .ci-config/Dockerfile.nightly, not # the literal develop tip; the tip moving is covered separately by protocol-watch. +# +# The pin is therefore this check's horizon: against a stale pin the diff reports +# "in sync" about the past. nightly-pin-watch.yml keeps it moving. on: schedule: diff --git a/.github/workflows/nightly-pin-watch.yml b/.github/workflows/nightly-pin-watch.yml new file mode 100644 index 00000000..8b8b8459 --- /dev/null +++ b/.github/workflows/nightly-pin-watch.yml @@ -0,0 +1,308 @@ +name: Nightly Pin Watch + +# Weekly check that the nightly stand is not pinned to a stale xrpld build. +# +# definitions-watch raises its stand from docker-compose.batchv11.yml, i.e. from +# ARG XRPLD_VERSION in .ci-config/Dockerfile.nightly. While that pin is stale the +# weekly definitions diff compares definitions.json against a build older than +# the one CI runs and reports "in sync" about the past — which is how rippled +# 3.3.0 renaming sfMutableFlags to sfImmutableFlags and moving SponsorshipSet +# onto delta fields reached the CI bump PR unnoticed. +# +# The pin cannot simply be dropped: the nightly apt channel switched its build +# timestamp from 14 to 12 digits mid-2026, so Debian version ordering ranks old +# builds above new ones and an unpinned install gets a stale binary. Hence a +# workflow that moves the pin deliberately instead. +# +# Bumping is deliberately NOT done every week: nightly publishes several builds +# a day and a weekly PR would be noise. MAX_PIN_AGE_DAYS is the staleness the +# monitor is allowed to carry. +# +# Credentials follow the same ladder as release-watch (a PR opened with +# GITHUB_TOKEN does not trigger CI): +# 1. GitHub App: RELEASE_WATCH_APP_ID variable + RELEASE_WATCH_APP_PRIVATE_KEY +# secret, app installed with contents:write + pull_requests:write +# 2. Fine-grained PAT: RELEASE_WATCH_PAT secret +# With neither — or when the stand fails to come up on the new pin — the run +# falls back to a comment on the tracking issue. + +on: + schedule: + - cron: '0 5 * * 1' # Mondays 05:00 UTC, ahead of the other watchers + workflow_dispatch: + inputs: + force: + description: 'Bump even if the pin is younger than MAX_PIN_AGE_DAYS' + type: boolean + default: false + +permissions: + contents: read + issues: write + pull-requests: read # gh pr list in the idempotency check runs on GITHUB_TOKEN + +concurrency: + group: nightly-pin-watch + cancel-in-progress: false + +env: + COMPOSE: .ci-config/docker-compose.batchv11.yml + MAX_PIN_AGE_DAYS: '21' + ISSUE_TITLE: 'Nightly pin watch: xrpld develop build' + ISSUE_LABEL: nightly-pin-watch + DOTNET_VERSION: '10.0.x' + HAS_APP: ${{ vars.RELEASE_WATCH_APP_ID != '' && secrets.RELEASE_WATCH_APP_PRIVATE_KEY != '' }} + CAN_PR: ${{ (vars.RELEASE_WATCH_APP_ID != '' && secrets.RELEASE_WATCH_APP_PRIVATE_KEY != '') || secrets.RELEASE_WATCH_PAT != '' }} + +jobs: + watch: + runs-on: ubuntu-latest + # Cold nightly image build (apt install of the pinned xrpld) plus a genesis + # start and the definitions diff; cap well under the 360-min default. + timeout-minutes: 30 + steps: + - name: Mint GitHub App token + id: app-token + if: env.HAS_APP == 'true' + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ vars.RELEASE_WATCH_APP_ID }} + private-key: ${{ secrets.RELEASE_WATCH_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + + - uses: actions/checkout@v4 + with: + token: ${{ steps.app-token.outputs.token || secrets.RELEASE_WATCH_PAT || github.token }} + + - name: Check the pin against the nightly channel + id: check + run: bash .ci-config/bump-nightly-pin.sh --check + + # A pin that is current, or stale by less than the allowance, is left + # alone: definitions-watch is still looking at a recent develop build. + - name: Decide whether to bump + id: decide + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OLD_VERSION: ${{ steps.check.outputs.old_version }} + NEW_VERSION: ${{ steps.check.outputs.new_version }} + AGE_DAYS: ${{ steps.check.outputs.age_days }} + FORCE: ${{ inputs.force }} + run: | + set -euo pipefail + if [ "$OLD_VERSION" = "$NEW_VERSION" ]; then + echo "Pin is already the newest published build." + echo "bump=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "$FORCE" != "true" ]; then + case "$AGE_DAYS" in + ''|*[!0-9]*) + echo "::warning::pin age could not be derived from '$OLD_VERSION'; bumping so an unparseable pin cannot sit forever" + ;; + *) + if [ "$AGE_DAYS" -lt "$MAX_PIN_AGE_DAYS" ]; then + echo "Pin is ${AGE_DAYS}d old, under the ${MAX_PIN_AGE_DAYS}d allowance; leaving it." + echo "bump=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + ;; + esac + fi + # git refs reject '~' (and a few other characters) — a raw upstream + # version like 3.4.0~b0+202608111815.26cc683e-1 cannot be a branch name. + # Sanitize by class rather than by the one character today's format + # happens to use, so a future version string cannot break the bump. + slug=$(printf '%s' "$NEW_VERSION" | sed -E 's/[^A-Za-z0-9._-]+/-/g') + branch="nightly-watch/$slug" + if ! git check-ref-format "refs/heads/$branch"; then + echo "::error::derived branch name is not a valid git ref: $branch" + exit 1 + fi + if [ -n "$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$branch" --state open --json number --jq '.[0].number // empty')" ]; then + echo "A bump PR for $NEW_VERSION is already open." + echo "bump=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "branch=$branch" >> "$GITHUB_OUTPUT" + echo "bump=true" >> "$GITHUB_OUTPUT" + + # Rewrites the pin and regenerates rippled.batchv11.cfg from the develop + # commit the new build was made from — the script derives the ref from the + # version string, so config and binary cannot drift apart. + - name: Bump the pin + if: steps.decide.outputs.bump == 'true' + id: bump + run: | + set -euo pipefail + old_names=$(sed -n -E 's/^[0-9A-F]{64} (.+)$/\1/p' .ci-config/rippled.batchv11.cfg | LC_ALL=C sort) + bash .ci-config/bump-nightly-pin.sh + new_names=$(sed -n -E 's/^[0-9A-F]{64} (.+)$/\1/p' .ci-config/rippled.batchv11.cfg | LC_ALL=C sort) + { + echo 'added<> "$GITHUB_OUTPUT" + git diff --stat + + - name: Start the stand on the new pin + if: steps.decide.outputs.bump == 'true' + id: smoke + continue-on-error: true + run: | + set -euo pipefail + docker compose -f "$COMPOSE" up -d --build + # AMM amendment id — the same sentinel the CI readiness wait uses. + # Genesis up-votes come from [amendments], so an enabled sentinel means + # the regenerated config was accepted rather than silently ignored. + SENTINEL="8CC0774A3BF66D1D22E76BBDA8E8A232E6B6313834301B3B23E8601196AE6455" + ok=false + for _ in $(seq 1 45); do + enabled=$(curl -sf --max-time 5 http://localhost:5005/ \ + -d "{\"method\":\"feature\",\"params\":[{\"feature\":\"$SENTINEL\"}]}" \ + | jq -r ".result.\"$SENTINEL\".enabled" 2>/dev/null || true) + if [ "$enabled" = "true" ]; then ok=true; break; fi + sleep 2 + done + if [ "$ok" != "true" ]; then + docker compose -f "$COMPOSE" logs --no-color xrpld | tail -80 || true + fi + [ "$ok" = "true" ] + + - name: Use .NET ${{ env.DOTNET_VERSION }} + if: steps.decide.outputs.bump == 'true' && steps.smoke.outcome == 'success' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + # Informational, never blocking: a definitions drift against the newer + # develop build is exactly what the bump exists to reveal, so it belongs in + # the PR body rather than stopping the PR from being opened. + - name: Diff definitions.json against the new build + if: steps.decide.outputs.bump == 'true' && steps.smoke.outcome == 'success' + id: definitions + continue-on-error: true + run: | + set -uo pipefail + out=$(dotnet run --project Tools/GenerateEnums -- diff http://localhost:5005 2>&1) + status=$? + echo "$out" + { + echo 'summary<> "$GITHUB_OUTPUT" + exit 0 + + - name: Stop the stand + if: always() + run: docker compose -f "$COMPOSE" down || true + + - name: Open bump PR + if: steps.decide.outputs.bump == 'true' && steps.smoke.outcome == 'success' && env.CAN_PR == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.RELEASE_WATCH_PAT }} + OLD_VERSION: ${{ steps.check.outputs.old_version }} + NEW_VERSION: ${{ steps.check.outputs.new_version }} + NEW_REF: ${{ steps.check.outputs.new_ref }} + AGE_DAYS: ${{ steps.check.outputs.age_days }} + BRANCH: ${{ steps.decide.outputs.branch }} + run: | + set -euo pipefail + git config user.name "${{ github.actor }}" + git config user.email "${{ github.actor }}@users.noreply.github.com" + git checkout -b "$BRANCH" + git add .ci-config/Dockerfile.nightly .ci-config/rippled.batchv11.cfg + git commit -m "ci: bump nightly stand pin to xrpld $NEW_VERSION" + git push -u origin "$BRANCH" --force + body_file=$(mktemp) + { + printf '## Summary\n\n' + printf 'Automated bump by the nightly-pin-watch workflow: the nightly stand was pinned to a build **%sd** old.\n\n' "$AGE_DAYS" + printf -- '- `.ci-config/Dockerfile.nightly`: `%s` -> `%s`\n' "$OLD_VERSION" "$NEW_VERSION" + printf -- '- `.ci-config/rippled.batchv11.cfg`: `[features]`/`[amendments]` regenerated from develop `%s`, the commit that build was made from\n\n' "$NEW_REF" + printf '## Amendment changes\n\n' + printf 'Added:\n```\n%s\n```\n\n' "${{ steps.bump.outputs.added }}" + printf 'Removed:\n```\n%s\n```\n\n' "${{ steps.bump.outputs.removed }}" + printf '## definitions.json vs the new build\n\n' + printf '```\n%s\n```\n\n' "${{ steps.definitions.outputs.summary }}" + printf 'A `node-only` field here means the SDK is behind develop and needs a follow-up; `local-only` entries are informational.\n\n' + printf '## Verification\n\n' + printf 'The stand was built and started from the new pin on the runner, and the AMM sentinel amendment came up enabled at genesis, so the regenerated config was accepted.\n\n' + printf 'Why this matters: definitions-watch raises its stand from this pin, so a stale pin makes the weekly develop-drift check compare against the past.\n' + } > "$body_file" + gh pr create --repo "$GITHUB_REPOSITORY" --base dev --head "$BRANCH" \ + --title "ci: bump nightly stand pin to xrpld $NEW_VERSION" \ + --body-file "$body_file" + + - name: Notify on tracking issue (no PR credentials or stand failure) + if: steps.decide.outputs.bump == 'true' && (steps.smoke.outcome != 'success' || env.CAN_PR != 'true') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + OLD_VERSION: ${{ steps.check.outputs.old_version }} + NEW_VERSION: ${{ steps.check.outputs.new_version }} + NEW_REF: ${{ steps.check.outputs.new_ref }} + AGE_DAYS: ${{ steps.check.outputs.age_days }} + SMOKE: ${{ steps.smoke.outcome }} + run: | + set -euo pipefail + if [ "$SMOKE" != "success" ]; then + reason="the stand FAILED to come up on the new pin — the build, the regenerated config or the new binary needs a human look (see the run log)" + else + reason="no PR credentials are configured (RELEASE_WATCH_APP_ID + RELEASE_WATCH_APP_PRIVATE_KEY, or RELEASE_WATCH_PAT), so a CI-triggering PR cannot be opened automatically" + fi + number=$(gh issue list --repo "$REPO" --label "$ISSUE_LABEL" --state open \ + --json number --jq '.[0].number // empty') + if [ -z "$number" ]; then + gh label create "$ISSUE_LABEL" --repo "$REPO" \ + --description "nightly xrpld pin tracking" --color 1d76db 2>/dev/null || true + number=$(gh issue create --repo "$REPO" --title "$ISSUE_TITLE" --label "$ISSUE_LABEL" \ + --body "Automated notifications from the nightly-pin-watch workflow (.github/workflows/nightly-pin-watch.yml)." \ + | grep -oE '[0-9]+$') + fi + marker="" + existing_comments=$(gh api "repos/$REPO/issues/$number/comments" --paginate --jq '.[].body') + if grep -qF "$marker" <<< "$existing_comments"; then + echo "Notification for $NEW_VERSION already posted." + exit 0 + fi + comment=$(printf '%s\nThe nightly stand is pinned to **%s** (%sd old); the newest published build is **%s**, but %s.\n\nManual path: run `.ci-config/bump-nightly-pin.sh`, start `%s` to check the stand comes up, open a PR.\nThe regenerated config must come from develop `%s` — the commit that build was made from.\n' \ + "$marker" "$OLD_VERSION" "$AGE_DAYS" "$NEW_VERSION" "$reason" "$COMPOSE" "$NEW_REF") + gh issue comment "$number" --repo "$REPO" --body "$comment" + + - name: Notify on tracking issue (workflow failure) + if: failure() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + number=$(gh issue list --repo "$REPO" --label "$ISSUE_LABEL" --state open \ + --json number --jq '.[0].number // empty') + if [ -z "$number" ]; then + gh label create "$ISSUE_LABEL" --repo "$REPO" \ + --description "nightly xrpld pin tracking" --color 1d76db 2>/dev/null || true + number=$(gh issue create --repo "$REPO" --title "$ISSUE_TITLE" --label "$ISSUE_LABEL" \ + --body "Automated notifications from the nightly-pin-watch workflow (.github/workflows/nightly-pin-watch.yml)." \ + | grep -oE '[0-9]+$') + fi + # One notification per failure streak, same rule as release-watch: + # skip only when the latest comment is already a failure notice. + marker='' + last_id=$(gh api "repos/$REPO/issues/$number/comments" --paginate --jq '.[].id' | tail -1) + if [ -n "$last_id" ]; then + last_body=$(gh api "repos/$REPO/issues/comments/$last_id" --jq .body) + if grep -qF "$marker" <<< "$last_body"; then + echo "Latest comment is already a failure notification; skipping." + exit 0 + fi + fi + comment=$(printf '%s\nNightly-pin-watch run **failed** before completing: %s/%s/actions/runs/%s\n\nThe pin was not changed; see the run log.\n' \ + "$marker" "$GITHUB_SERVER_URL" "$REPO" "${{ github.run_id }}") + gh issue comment "$number" --repo "$REPO" --body "$comment" diff --git a/.github/workflows/release-watch.yml b/.github/workflows/release-watch.yml index 7ebab21a..0e891647 100644 --- a/.github/workflows/release-watch.yml +++ b/.github/workflows/release-watch.yml @@ -18,9 +18,10 @@ name: Release Watch # # The nightly stand config (rippled.batchv11.cfg) is intentionally NOT touched # here: its amendment lists must match the pinned nightly xrpld build -# (ARG XRPLD_VERSION in Dockerfile.nightly), not the latest stable tag. -# Regenerate it manually when bumping the nightly pin: -# .ci-config/generate-amendments.sh .ci-config/rippled.batchv11.cfg +# (ARG XRPLD_VERSION in Dockerfile.nightly), not the latest stable tag. That pin +# has its own weekly watcher, nightly-pin-watch.yml, which bumps it once it is +# older than the allowance and regenerates the config from the matching develop +# commit. By hand: .ci-config/bump-nightly-pin.sh on: schedule: diff --git a/Base/Xrpl.BinaryCodec/Enums/Field.Amount.Generated.cs b/Base/Xrpl.BinaryCodec/Enums/Field.Amount.Generated.cs index 3f00774d..e74ebc63 100644 --- a/Base/Xrpl.BinaryCodec/Enums/Field.Amount.Generated.cs +++ b/Base/Xrpl.BinaryCodec/Enums/Field.Amount.Generated.cs @@ -35,5 +35,6 @@ public partial class Field public static readonly AmountField LPTokenBalance = new AmountField(nameof(LPTokenBalance), 31); public static readonly AmountField FeeAmount = new AmountField(nameof(FeeAmount), 32); public static readonly AmountField MaxFee = new AmountField(nameof(MaxFee), 33); + public static readonly AmountField FeeAmountDelta = new AmountField(nameof(FeeAmountDelta), 34); } } diff --git a/Base/Xrpl.BinaryCodec/Enums/Field.Int32.Generated.cs b/Base/Xrpl.BinaryCodec/Enums/Field.Int32.Generated.cs index 3bace273..8db8b6d9 100644 --- a/Base/Xrpl.BinaryCodec/Enums/Field.Int32.Generated.cs +++ b/Base/Xrpl.BinaryCodec/Enums/Field.Int32.Generated.cs @@ -7,5 +7,6 @@ namespace Xrpl.BinaryCodec.Enums public partial class Field { public static readonly Int32Field LoanScale = new Int32Field(nameof(LoanScale), 1); + public static readonly Int32Field RemainingOwnerCountDelta = new Int32Field(nameof(RemainingOwnerCountDelta), 2); } } diff --git a/Base/Xrpl.BinaryCodec/Enums/Field.Uint32.Generated.cs b/Base/Xrpl.BinaryCodec/Enums/Field.Uint32.Generated.cs index c611d165..64e90439 100644 --- a/Base/Xrpl.BinaryCodec/Enums/Field.Uint32.Generated.cs +++ b/Base/Xrpl.BinaryCodec/Enums/Field.Uint32.Generated.cs @@ -56,7 +56,7 @@ public partial class Field public static readonly Uint32Field FirstNFTokenSequence = new Uint32Field(nameof(FirstNFTokenSequence), 50); public static readonly Uint32Field OracleDocumentID = new Uint32Field(nameof(OracleDocumentID), 51); public static readonly Uint32Field PermissionValue = new Uint32Field(nameof(PermissionValue), 52); - public static readonly Uint32Field MutableFlags = new Uint32Field(nameof(MutableFlags), 53); + public static readonly Uint32Field ImmutableFlags = new Uint32Field(nameof(ImmutableFlags), 53); public static readonly Uint32Field StartDate = new Uint32Field(nameof(StartDate), 54); public static readonly Uint32Field PaymentInterval = new Uint32Field(nameof(PaymentInterval), 55); public static readonly Uint32Field GracePeriod = new Uint32Field(nameof(GracePeriod), 56); diff --git a/Base/Xrpl.BinaryCodec/Enums/definitions.json b/Base/Xrpl.BinaryCodec/Enums/definitions.json index 14555c74..62446095 100644 --- a/Base/Xrpl.BinaryCodec/Enums/definitions.json +++ b/Base/Xrpl.BinaryCodec/Enums/definitions.json @@ -691,7 +691,7 @@ } ], [ - "MutableFlags", + "ImmutableFlags", { "isSerialized": true, "isSigningField": true, @@ -2490,6 +2490,16 @@ "type": "Int32" } ], + [ + "RemainingOwnerCountDelta", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 2, + "type": "Int32" + } + ], [ "TransactionMetaData", { @@ -3490,6 +3500,16 @@ "type": "Amount" } ], + [ + "FeeAmountDelta", + { + "isSerialized": true, + "isSigningField": true, + "isVLEncoded": false, + "nth": 34, + "type": "Amount" + } + ], [ "RemainingOwnerCount", { diff --git a/CHANGES.md b/CHANGES.md index 9b0ed1a7..57b2c455 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,19 @@ ## 10.11.0.0 08/04/2026 +* **rippled 3.3.0 — the CI stand and two protocol surfaces the SDK had modelled from a stale `develop` snapshot** (**breaking**). The stand moves from 3.2.1 to the 3.3.0 release image, which activates `BatchV1_1`, `Sponsor`, `PermissionDelegationV1_1`, `DynamicMPT`, `ConfidentialTransfer` and `fixCleanup3_3_0` at genesis, so 44 previously `AmendmentGuard`-skipped integration tests run for real on every CI run. 17 of them failed there: both features had been implemented against the nightly build the stand is pinned to (`3.3.0~b1+202607110018`, 11 Jul 2026) and upstream changed their shape before the release. Neither change is visible in `definitions.json` field codes alone, which is why nothing caught it earlier — see the Definitions Watch note below: + * **DynamicMPT: `MutableFlags` is now `ImmutableFlags`, with the meaning inverted.** Same `UInt32` field, same nth 53, same bit values — but a set bit no longer means "this may be changed later", it means "this is frozen forever" (`rippled` `MPTokenIssuanceSet::preclaim`: `isImmutable(flag) => currentImmutableFlags & flag`). An issuance created without the field is therefore fully mutable, where before it was fully immutable — the exact opposite default. Because the field code did not change, the old models produced a blob the node accepted and then read backwards: mutations came back `tecNO_PERMISSION`, freezes silently succeeded, and `ledger_entry` returned an `ImmutableFlags` key the model did not bind. `MPTokenIssuanceCreateMutableFlags` and `MPTokenIssuanceSetMutableFlags` are replaced by a single `MPTokenIssuanceImmutableFlags` (`tif*`, aliasing the `lsif*` ledger constants) shared by both transactions and `LOMPTokenIssuance.ImmutableFlags` + * **DynamicMPT: enabling a capability moved from a field to transaction flags.** The old `MPTokenIssuanceSet.MutableFlags = tmfMPTSet*` no longer exists; a capability is now enabled through `Flags = tfMPTSetCanLock | tfMPTSetRequireAuth | tfMPTSetCanEscrow | tfMPTSetCanTrade | tfMPTSetCanTransfer | tfMPTSetCanClawback | tfMPTSetCanHoldConfidentialBalance` (0x04–0x100), added to `MPTokenIssuanceSetFlags` next to the existing `tfMPTLock`/`tfMPTUnlock`. `ImmutableFlags` on the same transaction now does the opposite job — it freezes capabilities and fields, OR-ed into the ledger object, never cleared + * **Sponsor: `SponsorshipSet` takes deltas, not absolute values.** `FeeAmount` and `RemainingOwnerCount` are fields of the `Sponsorship` **ledger object** only; the transaction carries `FeeAmountDelta` (`Amount`, nth 34) and `RemainingOwnerCountDelta` (**`Int32`**, nth 2) — signed changes applied to what the object already holds. Sending the old fields is not a semantic mismatch but a hard parse error: `STObject::applyTemplate` rejects any field outside the format with `invalidTransaction — Field 'FeeAmount' found in disallowed location`, which is what 13 of the 17 failures were. `SponsorshipSet.FeeAmount`/`RemainingOwnerCount` become `FeeAmountDelta` (`Currency`) / `RemainingOwnerCountDelta` (`int?`, signed — a negative delta reclaims budget); `LOSponsorship` is unchanged, it already matched the object. Client-side validation follows `SponsorshipSet::preflight`: a delta must be non-zero, `FeeAmountDelta` must be XRP, and `tfDeleteObject` may not carry any of the three modification fields + * `definitions.json` + the three generated `Field.*` partials carry the renamed and the two new fields; `Common.TryGetInt32` was added for the signed delta, the codec already had `Int32Type` +* **The nightly pin now has a watcher** — `nightly-pin-watch.yml`, weekly. The pin is what `definitions-watch` sees as "develop", so leaving it in place quietly narrows that check to whatever rippled looked like when the pin was last touched; the two 3.3.0 renames above sat undetected behind a pin from 11 July. Dropping the pin is not an option — the nightly build timestamp shrank from 14 to 12 digits mid-2026, so Debian version ordering ranks old builds above new ones and an unpinned install gets a stale binary: + * `.ci-config/bump-nightly-pin.sh` does the move: newest `xrpld` build from the nightly apt channel, `ARG XRPLD_VERSION` rewritten, `rippled.batchv11.cfg` regenerated from the develop commit **encoded in that version string** — config and binary cannot drift apart, which is the failure mode the old manual two-step invited. `--check` reports the pin, the newest build and the pin's age without touching anything. Both timestamp formats are compared by their common `YYYYMMDDHHMM` prefix + * the workflow bumps only once the pin is older than `MAX_PIN_AGE_DAYS` (21) — nightly publishes several builds a day, and a weekly PR would be noise rather than signal; `workflow_dispatch` takes a `force` input for the exceptions. It then builds and starts the stand on the new pin and requires the AMM sentinel amendment to come up enabled at genesis, which is what proves the regenerated config was accepted rather than silently ignored, and attaches the definitions diff against the new build to the PR body — a `node-only` field there is the SDK being behind develop, reported instead of hidden + * credentials, idempotency and the tracking-issue fallback follow release-watch exactly, including the one-notification-per-failure-streak rule +* **The conformance fixtures are re-pinned to the 3.3.0 tag** — `transactions.macro` and `LedgerFormats.h` now come from the release commit (`00a178fb`) instead of a July `develop` sha and `3.3.0-rc1`; `ledger_entries.macro` stays on `develop` (`9859e5ce`) for the reason its `.ref` already gives — `sfLEVersion` exists only there. Both macro files are byte-identical to upstream and re-verifiable with the `curl … | diff` line in each `.ref`. This is what makes the guards test against the version CI actually runs: + * `RippledLedgerFlags.Parse` learned to read the `lsif*` values. In 3.3.0 they are no longer a `LEDGER_OBJECT(MPTokenIssuanceMutable, …)` block but plain `inline constexpr std::uint32_t` constants next to the macro list, so the flag guard would have quietly lost that enum entirely. They are reported under a synthetic `MPTokenIssuanceImmutable` object, and a parse that finds none of them now throws instead of returning a thinner table +* **Why the weekly Definitions Watch stayed green through all of this** — `definitions-watch.yml` raises a stand from `docker-compose.batchv11.yml`, i.e. the **pinned** nightly `XRPLD_VERSION`. While the pin is stale the monitor diffs `definitions.json` against a build older than the one CI runs, and reports "in sync" about the past. The pin needs to move with every stable bump, not only when a new amendment is wanted + * **Autofill covers the three remaining transactors with a special base fee** — `Transactor::calculateBaseFee` is overridden by ten transactors upstream, and the fee sugar implemented only some of them. The three that were missing all **underpay**, which is the failing direction: a fee below the required minimum is rejected with `telINSUF_FEE_P` instead of being topped up. Each is verified against the rippled source rather than inferred from the field layout: * **`LoanSet` no longer assumes a single counterparty signature.** The old formula was a flat `baseFee * 2`, correct only when the counterparty signs with its master key. `LoanSet::calculateBaseFee` charges one base fee per entry of `CounterpartySignature.Signers`, so a counterparty that multi-signs made the autofilled fee too low. When the signature is already attached — `LoanSigningHelper` ran first — its signers are counted directly; when it is not, which is the usual order during autofill, the counterparty's signer list size is fetched and used, matching what xrpl.js does for the same transaction. Absent signer list, or an account that does not exist yet, falls back to one signature * **`LoanPay` charges per five payments processed.** `LoanPay::calculateBaseFee` multiplies the *whole* Transactor cost — signatures included — by one increment per `kLoanPaymentsPerFeeIncrement` (5) payments the transaction is expected to make, capped at `kLoanMaximumPaymentsPerTransaction / 5` (20). Paying off six or more scheduled payments in one transaction therefore costs at least twice the base fee, and nothing in the SDK accounted for it. The estimate reads the `Loan` object, derives the per-payment amount as `roundPeriodicPayment(PeriodicPayment, LoanScale) + LoanServiceFee` — rounding up to whole units for XRP and MPT, to a multiple of `10^LoanScale` for IOUs, as `roundToAsset` does — and divides the transaction `Amount` by it. Every path rippled short-circuits is mirrored: `tfLoanFullPayment` and `tfLoanLatePayment` do one set of calculations, `PaymentRemaining <= 5` needs no increments, and an unreadable `Loan` object falls back to the normal cost the same way rippled leaves the error to preclaim. The asset's integrality is taken from the transaction's own `Amount`, which rippled requires to match the vault asset, so no broker/vault lookups are needed @@ -37,7 +50,7 @@ * `Xrpl.BinaryCodec` bumped to **10.11.0.0**, aligned with `Xrpl` rather than to its own next minor (10.10.0.0): the codec ships the field, so the two move together and a consumer can read one version number off both. 10.10.x is simply skipped — the codec's last published version is 10.9.0, so no number is being reused. `Xrpl.AddressCodec` and `Xrpl.Keypairs` are untouched and keep 10.9.0.0 * **Ledger-object flags the protocol declares but the models never named** — an unnamed bit still arrives in the model as a number, so reading the object kept working and only the consumer's ability to test it by name was lost. That is why these went unnoticed; a field-by-field diff of rippled `LedgerFormats.h` (tag `3.3.0-rc1`) against every flag enum found four gaps: - * `MPTokenIssuanceFlags` + **`MPTCanHoldConfidentialBalance`** (0x80) — introduced by ConfidentialTransfer. The rest of the amendment was already complete (transactions 85–89, `IssuerEncryptionKey`/`AuditorEncryptionKey`, `ConfidentialOutstandingAmount`); only the flag had no name. Value confirmed against a live node: `MPTokenIssuanceSet` with `MutableFlags = tmfMPTSetCanHoldConfidentialBalance` moves the issuance from `Flags = 0` to `Flags = 128` + * `MPTokenIssuanceFlags` + **`MPTCanHoldConfidentialBalance`** (0x80) — introduced by ConfidentialTransfer. The rest of the amendment was already complete (transactions 85–89, `IssuerEncryptionKey`/`AuditorEncryptionKey`, `ConfidentialOutstandingAmount`); only the flag had no name. Value confirmed against a live node: `MPTokenIssuanceSet` with `Flags = tfMPTSetCanHoldConfidentialBalance` moves the issuance from `Flags = 0` to `Flags = 128` * `MPTokenFlags` + **`lsfMPTAMM`** (0x4) — a much older gap: the flag is present as far back as 3.2.1. `AMMCreate` sets it together with `lsfMPTAuthorized` to implicitly authorize an MPT asset for the AMM pseudo-account * **`LOLoan.Flags`** — the Loan ledger object had no `Flags` property at all (and `BaseLedgerEntry` has none either), so `lsfLoanDefault`/`lsfLoanImpaired`/`lsfLoanOverpayment` were unreadable through the typed model: the default and impairment state of a loan could not be observed at all. Added as a typed `LoanFlags?` together with the enum * new `SignerListFlags` (`lsfOneOwnerCount`) and `DirectoryNodeFlags` (`lsfNFTokenBuyOffers`/`lsfNFTokenSellOffers`) — both objects expose `Flags` as a raw `uint` and **keep doing so** (changing the property type would be breaking); the enums give consumers named constants to test bits against instead of magic numbers. The `LODirectoryNode.Flags` comment claiming "the protocol defines no flags for DirectoryNode objects" was false and is corrected @@ -46,16 +59,16 @@ * `Tests/Xrpl.Tests/Fixtures/LedgerFormats.h` is vendored byte-identical and pinned by sha in `LedgerFormats.h.ref`, verifiable with a plain `curl … | diff`. Pinned rather than live for the same reason as `transactions.macro`: upstream drift is protocol-watch's job, and a network-backed test would go red on Ripple's release schedule instead of ours * `RippledLedgerFlags` parses the `LEDGER_OBJECT`/`LSF_FLAG` macro text and fails loudly — an unknown `LSF_FLAG*` variant or a parse yielding fewer than 10 objects / 50 flags throws rather than leaving the test green on an empty table * the test diffs **both directions** (a flag rippled declares and the enum lacks, a flag the enum has and rippled does not) and requires every flagged object to be registered against a model enum, so a newly added ledger object fails the build instead of being skipped. `tf*` members sharing an enum with ledger flags (`OfferFlags.tfInnerBatchTxn`) and zero-valued members are excluded by rule - * name matching normalizes the `lsf`/`lsmf`/`tmf` prefixes, so `lsfMPTLocked` ≡ `MPTLocked` and rippled's `lsmfMPTCanEnableCanLock` ≡ the SDK's `tmfMPTCanEnableCanLock` (rippled itself aliases `tmfX = lsmfX` in `TxFlags.h`) + * name matching normalizes the `lsf`/`lsif`/`tif` prefixes, so `lsfMPTLocked` ≡ `MPTLocked` and rippled's `lsifMPTCanLock` ≡ the SDK's `tifMPTCanLock` (rippled itself aliases `tifX = lsifX` in `TxFlags.h`) * verified by mutation, not just by passing: a wrong value, a removed flag and an unregistered object each make it fail with a readable message * `protocol-watch` now watches `include/xrpl/protocol/LedgerFormats.h` as well. A pinned fixture cannot notice upstream moving — that signal is the watcher's job, and the header was missing from its list (which is the other half of why `lsfMPTAMM` went unnoticed for so long). The first run after this change reports the header as changed once, then carries it in the baseline like the rest -* **DynamicMPT (XLS-94) integration coverage** — the `MutableFlags` fields existed on the models but had never been exercised against a node. `AmendmentGuard` gains the `DynamicMPT` id (it matches what `generate-amendments.sh` already writes into the nightly stand's `[amendments]`), and `TestIDynamicMPT` covers the amendment end to end, each test reading the result back from the ledger object rather than trusting `EngineResult`: - * `MutableFlags` set at `MPTokenIssuanceCreate` reach `LOMPTokenIssuance` unchanged - * `MPTokenIssuanceSet` mutates `TransferFee` and `MPTokenMetadata`, and leaves `MutableFlags` alone (rippled `doApply` only writes `sfFlags`/`sfTransferFee`/`sfMPTokenMetadata`) - * `tmfMPTSetCanLock` raises `lsfMPTCanLock` on an issuance created **without** that capability - * a mutation the issuance never permitted is rejected with `tecNO_PERMISSION` and leaves the metadata untouched - * scenarios were derived from the transactor (`src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp` @ `3.3.0-rc1`), not from the docs — hence `tfMPTCanTransfer` at creation in the fee test: `preclaim` requires `lsfMPTCanTransfer` to be **already** set, and enabling it in the same transaction does not satisfy the rule +* **DynamicMPT (XLS-94) integration coverage** — the immutability fields existed on the models but had never been exercised against a node. `AmendmentGuard` gains the `DynamicMPT` id (it matches what `generate-amendments.sh` already writes into the nightly stand's `[amendments]`), and `TestIDynamicMPT` covers the amendment end to end, each test reading the result back from the ledger object rather than trusting `EngineResult`: + * `ImmutableFlags` set at `MPTokenIssuanceCreate` reach `LOMPTokenIssuance` unchanged + * `MPTokenIssuanceSet` mutates `TransferFee` and `MPTokenMetadata` on an issuance that froze neither, and leaves `ImmutableFlags` unset (`doApply` only ORs that field when the transaction carries it) + * `tfMPTSetCanLock` raises `lsfMPTCanLock` on an issuance created **without** that capability + * a mutation of a frozen field is rejected with `tecNO_PERMISSION` and leaves the metadata untouched, whether the freeze came from the create or from a later set + * scenarios were derived from the transactor (`src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp` @ `3.3.0`), not from the docs — hence `tfMPTCanTransfer` at creation in the fee test: `preclaim` requires `lsfMPTCanTransfer` to be **already** set, and enabling it in the same transaction does not satisfy the rule * amendment-gated, so it skips on the CI stand (rippled 3.2.x has `DynamicMPT` as `Supported::No`) and runs for real on the nightly stand * **An exception from an `OnConnected` handler no longer kills the client forever** — `Connection.OnceOpen` caught anything thrown by a consumer `OnConnected` handler and called `Disconnect()`, i.e. the *user* disconnect path: it set `_permanentlyDisconnected = true` and called `ClearReconnectState()`. After that the client was dead — the reconnect loop was never restarted, no new socket was ever opened, `OnConnected` never fired again, and every later request threw `NotConnectedException("Client has been disconnected. Call Connect() to reconnect.")`. Nothing was logged and nothing was raised, so from the outside the client just went quiet: diff --git a/CLAUDE.md b/CLAUDE.md index 3e4a6e4a..c14ddf33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,7 +175,7 @@ docker compose -f .ci-config/docker-compose.batchv11.yml down Amendment-dependent test classes use `Tests/Xrpl.Tests/Integration/AmendmentGuard.cs`: `ClassInitialize` checks the Amendments ledger object and marks tests inconclusive (skipped, exit 0) when the amendment is not active — so these tests are safe on the CI stand and run for real on the nightly stand. To gate a new test class, add the amendment id constant to `AmendmentGuard` and call `Assert.Inconclusive` from `TestInitialize` when inactive. Nightly stand specifics (see comments in `Dockerfile.nightly` / `rippled.batchv11.cfg`): -- rippled was renamed to **xrpld** on `develop`; the nightly apt channel publishes the `xrpld` package. The version must be **pinned**: the build-timestamp format shrank from 14 to 12 digits in mid-2026, so Debian version ordering ranks older builds above newer ones. To bump: pick the latest `xrpld` version from the `jammy nightly` Packages index at repos.ripple.com, update `ARG XRPLD_VERSION`, and regenerate the nightly amendment lists with `.ci-config/generate-amendments.sh .ci-config/rippled.batchv11.cfg` (the ref must match the pinned build — a newer ref can emit feature names unknown to the binary, which rejects them at startup). +- rippled was renamed to **xrpld** on `develop`; the nightly apt channel publishes the `xrpld` package. The version must be **pinned**: the build-timestamp format shrank from 14 to 12 digits in mid-2026, so Debian version ordering ranks older builds above newer ones. To bump, run `.ci-config/bump-nightly-pin.sh` (`--check` reports without changing anything): it picks the newest `xrpld` build from the `jammy nightly` Packages index at repos.ripple.com, rewrites `ARG XRPLD_VERSION` and regenerates the nightly amendment lists from the develop commit encoded in that version string. The ref must match the pinned build — a newer ref can emit feature names unknown to the binary, which rejects them at startup — which is why the script derives it from the version rather than taking it as an argument. `nightly-pin-watch.yml` runs the same script weekly and opens a PR once the pin is older than its allowance. - On xrpld (3.2.x and `develop`) the two amendment config sections do different jobs. `[amendments]` (` `, hash = sha512half of the name) registers genesis up-votes at `--start` — amendments become enabled **on-ledger** (Amendments object, `feature` RPC), which is what `AmendmentGuard`-gated tests check; only Supported::Yes amendments — unsupported ones (e.g. MPTokensV2 on 3.2.0) are skipped at genesis. `[features]` does NOT vote (startup logs say "will be down voted by default") but it DOES feed Rules presets: listed amendments are treated as active during **transaction processing** even when not enabled on-ledger — this is the only way to exercise Supported::No code paths (MPTokensV2 MPT-AMM tests run via this mechanism) and the reason `[features]` may be a superset of `[amendments]`. Without `[amendments]`, introspection reports everything disabled and guard-gated tests skip, even though preset-covered transactors work. Both sections are regenerated by `.ci-config/generate-amendments.sh ` — do not edit the lists by hand. ### Generate Documentation @@ -198,6 +198,8 @@ Output goes to `docs/` directory. Published to GitHub Pages. | `docs.yml` | Push to `release` | DocFx → GitHub Pages | | `protocol-watch.yml` | Weekly cron (Mon 06:00 UTC); manual | Diffs rippled develop `*.macro` protocol files vs the baseline in the `protocol-watch` tracking issue; comments there on changes | | `release-watch.yml` | Weekly cron (Mon 06:30 UTC); manual | Checks the CI stand against the latest stable rippled release; when a newer release has a Docker image, regenerates the stand config (`generate-amendments.sh`), smoke-tests it and opens a bump PR (via GitHub App `RELEASE_WATCH_APP_ID`/`RELEASE_WATCH_APP_PRIVATE_KEY` or `RELEASE_WATCH_PAT`; falls back to a `release-watch` issue comment) | +| `definitions-watch.yml` | Weekly cron (Mon 07:00 UTC); manual | Raises the nightly stand and diffs `definitions.json` against its `server_definitions`; red when the node has fields the SDK lacks. Its horizon is the nightly pin, so a stale pin makes it report on the past | +| `nightly-pin-watch.yml` | Weekly cron (Mon 05:00 UTC); manual | Checks the nightly `xrpld` pin against the nightly apt channel; once it is older than `MAX_PIN_AGE_DAYS` (21), runs `bump-nightly-pin.sh`, starts the stand on the new pin, and opens a bump PR (same credential ladder as release-watch; falls back to a `nightly-pin-watch` issue comment) | Integration tests do **not** run on PRs into `dev` — `dev` uses a GitHub merge queue, and the `integration` job runs on the `merge_group` event against the merge result before the merge lands ("Merge when ready" button). Promotion PRs into `release` still run the full suite directly. New pushes to a PR cancel its in-flight CI run (`concurrency`, PR events only). diff --git a/DocFx/ConfidentialMPT-Guide.md b/DocFx/ConfidentialMPT-Guide.md index 37c2bb7d..77542e3a 100644 --- a/DocFx/ConfidentialMPT-Guide.md +++ b/DocFx/ConfidentialMPT-Guide.md @@ -53,7 +53,7 @@ var set = new MPTokenIssuanceSet { Account = issuer.ClassicAddress, MPTokenIssuanceID = issuanceId, - MutableFlags = MPTokenIssuanceSetMutableFlags.tmfMPTSetCanHoldConfidentialBalance, + Flags = MPTokenIssuanceSetFlags.tfMPTSetCanHoldConfidentialBalance, IssuerEncryptionKey = issuerElGamalPubKeyHex, }; ``` @@ -61,7 +61,7 @@ var set = new MPTokenIssuanceSet Rules enforced by rippled preflight (mirrored by SDK validation): - a non-zero `TransferFee` **cannot** be combined with enabling confidential balances (`temBAD_TRANSFER_FEE`); -- at issuance creation, `tmfMPTCannotEnableCanHoldConfidentialBalance` permanently forbids enabling privacy later; +- `tifMPTCanHoldConfidentialBalance` in `ImmutableFlags` — on the create or on any later set — permanently forbids enabling privacy; - an `AuditorEncryptionKey` requires an `IssuerEncryptionKey`. --- @@ -92,7 +92,7 @@ All amounts encrypted under holder/issuer/auditor keys are supplied by the **pro ## Ledger Objects -- `LOMPTokenIssuance`: `IssuerEncryptionKey`, `AuditorEncryptionKey`, `ConfidentialOutstandingAmount` (decimal string — a base-ten UInt64 field), `MutableFlags` +- `LOMPTokenIssuance`: `IssuerEncryptionKey`, `AuditorEncryptionKey`, `ConfidentialOutstandingAmount` (decimal string — a base-ten UInt64 field), `ImmutableFlags` - `LOMPToken`: confidential balance/inbox fields (encrypted blobs + counters) --- diff --git a/DocFx/ConfidentialMPT-Guide.ru.md b/DocFx/ConfidentialMPT-Guide.ru.md index 1476b3a5..298cf2ed 100644 --- a/DocFx/ConfidentialMPT-Guide.ru.md +++ b/DocFx/ConfidentialMPT-Guide.ru.md @@ -53,7 +53,7 @@ var set = new MPTokenIssuanceSet { Account = issuer.ClassicAddress, MPTokenIssuanceID = issuanceId, - MutableFlags = MPTokenIssuanceSetMutableFlags.tmfMPTSetCanHoldConfidentialBalance, + Flags = MPTokenIssuanceSetFlags.tfMPTSetCanHoldConfidentialBalance, IssuerEncryptionKey = issuerElGamalPubKeyHex, }; ``` @@ -61,7 +61,7 @@ var set = new MPTokenIssuanceSet Правила preflight rippled (продублированы клиентской валидацией SDK): - ненулевой `TransferFee` **несовместим** со включением конфиденциальных балансов (`temBAD_TRANSFER_FEE`); -- при создании выпуска флаг `tmfMPTCannotEnableCanHoldConfidentialBalance` навсегда запрещает включение приватности в будущем; +- флаг `tifMPTCanHoldConfidentialBalance` в `ImmutableFlags` — при создании выпуска или в любой последующей транзакции — навсегда запрещает включение приватности; - `AuditorEncryptionKey` требует наличия `IssuerEncryptionKey`. --- @@ -92,7 +92,7 @@ var set = new MPTokenIssuanceSet ## Объекты леджера -- `LOMPTokenIssuance`: `IssuerEncryptionKey`, `AuditorEncryptionKey`, `ConfidentialOutstandingAmount` (decimal-строка — base-ten UInt64 поле), `MutableFlags` +- `LOMPTokenIssuance`: `IssuerEncryptionKey`, `AuditorEncryptionKey`, `ConfidentialOutstandingAmount` (decimal-строка — base-ten UInt64 поле), `ImmutableFlags` - `LOMPToken`: поля конфиденциального баланса/inbox (зашифрованные блобы + счётчики) --- diff --git a/DocFx/Sponsorship-Guide.md b/DocFx/Sponsorship-Guide.md index 2d150840..3163c67b 100644 --- a/DocFx/Sponsorship-Guide.md +++ b/DocFx/Sponsorship-Guide.md @@ -63,6 +63,11 @@ Created by `SponsorshipSet`, one per sponsor/sponsee pair: | `FeeAmount` | Remaining XRP budget for sponsored fees | | `RemainingOwnerCount` | How many more objects the sponsor will cover reserves for | +Both are ledger-object fields only. The transaction adjusts them with the signed +`FeeAmountDelta` / `RemainingOwnerCountDelta` fields — a positive delta tops the budget +up, a negative one returns it to the sponsor. Sending the absolute names in a +transaction is rejected outright (`Field 'FeeAmount' found in disallowed location`). + ### Require-signature mode By default a sponsee can spend the sponsorship budget without the sponsor's participation. `SponsorshipSet` flags flip that per dimension: @@ -91,14 +96,14 @@ var setup = new SponsorshipSet { Account = sponsor.ClassicAddress, Sponsee = sponsee.ClassicAddress, - FeeAmount = new Currency { ValueAsXrp = 5m }, - RemainingOwnerCount = 3, + FeeAmountDelta = new Currency { ValueAsXrp = 5m }, + RemainingOwnerCountDelta = 3, }; setup = await client.Autofill(setup); await client.SubmitAndWait(setup, sponsor, true); // The sponsee deletes its own sponsorship (names the sponsor); -// deletion forbids the modification flags and FeeAmount/MaxFee/RemainingOwnerCount: +// deletion forbids the modification flags and FeeAmountDelta/MaxFee/RemainingOwnerCountDelta: var deletion = new SponsorshipSet { Account = sponsee.ClassicAddress, @@ -132,8 +137,8 @@ var tx = new SponsorshipSet { Account = sponsor.ClassicAddress, Sponsee = sponsee.ClassicAddress, - FeeAmount = new Currency { ValueAsXrp = 5m }, - RemainingOwnerCount = 3, + FeeAmountDelta = new Currency { ValueAsXrp = 5m }, + RemainingOwnerCountDelta = 3, }; tx = await client.Autofill(tx); TransactionSummary result = await client.SubmitAndWait(tx, sponsor, true); diff --git a/DocFx/Sponsorship-Guide.ru.md b/DocFx/Sponsorship-Guide.ru.md index 434cbe0a..40fe6a3c 100644 --- a/DocFx/Sponsorship-Guide.ru.md +++ b/DocFx/Sponsorship-Guide.ru.md @@ -63,6 +63,11 @@ | `FeeAmount` | Остаток XRP-бюджета на спонсируемые комиссии | | `RemainingOwnerCount` | Сколько ещё объектов спонсор покроет резервами | +Оба поля существуют только у объекта леджера. Транзакция меняет их знаковыми полями +`FeeAmountDelta` / `RemainingOwnerCountDelta`: положительная дельта пополняет бюджет, +отрицательная возвращает его спонсору. Абсолютные имена в транзакции нода отвергает +(`Field 'FeeAmount' found in disallowed location`). + ### Режим обязательной подписи По умолчанию спонсируемый тратит бюджет без участия спонсора. Флаги `SponsorshipSet` переключают это по-измеренно: @@ -91,14 +96,14 @@ var setup = new SponsorshipSet { Account = sponsor.ClassicAddress, Sponsee = sponsee.ClassicAddress, - FeeAmount = new Currency { ValueAsXrp = 5m }, - RemainingOwnerCount = 3, + FeeAmountDelta = new Currency { ValueAsXrp = 5m }, + RemainingOwnerCountDelta = 3, }; setup = await client.Autofill(setup); await client.SubmitAndWait(setup, sponsor, true); // Спонсируемый сам удаляет своё спонсорство (указывает спонсора); -// при удалении запрещены модификационные флаги и FeeAmount/MaxFee/RemainingOwnerCount: +// при удалении запрещены модификационные флаги и FeeAmountDelta/MaxFee/RemainingOwnerCountDelta: var deletion = new SponsorshipSet { Account = sponsee.ClassicAddress, @@ -132,8 +137,8 @@ var tx = new SponsorshipSet { Account = sponsor.ClassicAddress, Sponsee = sponsee.ClassicAddress, - FeeAmount = new Currency { ValueAsXrp = 5m }, - RemainingOwnerCount = 3, + FeeAmountDelta = new Currency { ValueAsXrp = 5m }, + RemainingOwnerCountDelta = 3, }; tx = await client.Autofill(tx); TransactionSummary result = await client.SubmitAndWait(tx, sponsor, true); diff --git a/Tests/Xrpl.Tests/Fixtures/LedgerFormats.h b/Tests/Xrpl.Tests/Fixtures/LedgerFormats.h index 7c504f6b..68205e27 100644 --- a/Tests/Xrpl.Tests/Fixtures/LedgerFormats.h +++ b/Tests/Xrpl.Tests/Fixtures/LedgerFormats.h @@ -190,17 +190,6 @@ enum LedgerEntryType : std::uint16_t { LSF_FLAG(lsfMPTCanClawback, 0x00000040) \ LSF_FLAG(lsfMPTCanHoldConfidentialBalance, 0x00000080)) \ \ - LEDGER_OBJECT(MPTokenIssuanceMutable, \ - LSF_FLAG(lsmfMPTCanEnableCanLock, 0x00000002) \ - LSF_FLAG(lsmfMPTCanEnableRequireAuth, 0x00000004) \ - LSF_FLAG(lsmfMPTCanEnableCanEscrow, 0x00000008) \ - LSF_FLAG(lsmfMPTCanEnableCanTrade, 0x00000010) \ - LSF_FLAG(lsmfMPTCanEnableCanTransfer, 0x00000020) \ - LSF_FLAG(lsmfMPTCanEnableCanClawback, 0x00000040) \ - LSF_FLAG(lsmfMPTCannotEnableCanHoldConfidentialBalance, 0x00000080) \ - LSF_FLAG(lsmfMPTCanMutateMetadata, 0x00010000) \ - LSF_FLAG(lsmfMPTCanMutateTransferFee, 0x00020000)) \ - \ LEDGER_OBJECT(MPToken, \ LSF_FLAG2(lsfMPTLocked, 0x00000001) \ LSF_FLAG(lsfMPTAuthorized, 0x00000002) \ @@ -294,6 +283,17 @@ getAllLedgerFlags() #pragma pop_macro("TO_MAP") #pragma pop_macro("ALL_LEDGER_FLAGS") +// MPTokenIssuance ImmutableFlags (sfImmutableFlags) +inline constexpr std::uint32_t lsifMPTCanLock = 0x00000002; +inline constexpr std::uint32_t lsifMPTRequireAuth = 0x00000004; +inline constexpr std::uint32_t lsifMPTCanEscrow = 0x00000008; +inline constexpr std::uint32_t lsifMPTCanTrade = 0x00000010; +inline constexpr std::uint32_t lsifMPTCanTransfer = 0x00000020; +inline constexpr std::uint32_t lsifMPTCanClawback = 0x00000040; +inline constexpr std::uint32_t lsifMPTCanHoldConfidentialBalance = 0x00000080; +inline constexpr std::uint32_t lsifMPTMetadata = 0x00010000; +inline constexpr std::uint32_t lsifMPTTransferFee = 0x00020000; + //------------------------------------------------------------------------------ /** diff --git a/Tests/Xrpl.Tests/Fixtures/LedgerFormats.h.ref b/Tests/Xrpl.Tests/Fixtures/LedgerFormats.h.ref index f776654f..42e1d291 100644 --- a/Tests/Xrpl.Tests/Fixtures/LedgerFormats.h.ref +++ b/Tests/Xrpl.Tests/Fixtures/LedgerFormats.h.ref @@ -1,12 +1,12 @@ -https://github.com/XRPLF/rippled/blob/3.3.0-rc1/include/xrpl/protocol/LedgerFormats.h -sha 18e311e1e245bcc1813363bd1771b94931585d80 -date 2026-07-16T13:54:12Z -tag 3.3.0-rc1 +https://github.com/XRPLF/rippled/blob/3.3.0/include/xrpl/protocol/LedgerFormats.h +sha 00a178fb92ca49521b937ae1a99d863765ea8a90 +date 2026-08-06T16:34:39Z +tag 3.3.0 LedgerFormats.h is vendored byte-identical to the ref above so that it can be re-verified with a plain diff: - curl -sSL https://raw.githubusercontent.com/XRPLF/rippled/18e311e1e245bcc1813363bd1771b94931585d80/include/xrpl/protocol/LedgerFormats.h \ + curl -sSL https://raw.githubusercontent.com/XRPLF/rippled/00a178fb92ca49521b937ae1a99d863765ea8a90/include/xrpl/protocol/LedgerFormats.h \ | diff - Tests/Xrpl.Tests/Fixtures/LedgerFormats.h This is the only place the protocol states which lsf flags belong to which ledger diff --git a/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro b/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro index b6408581..ffcd025f 100644 --- a/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro +++ b/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro @@ -404,7 +404,7 @@ LEDGER_ENTRY(ltMPTOKEN_ISSUANCE, 0x007e, MPTokenIssuance, mpt_issuance, ({ {sfPreviousTxnID, SoeRequired}, {sfPreviousTxnLgrSeq, SoeRequired}, {sfDomainID, SoeOptional}, - {sfMutableFlags, SoeDefault}, + {sfImmutableFlags, SoeDefault}, {sfReferenceHolding, SoeOptional}, {sfIssuerEncryptionKey, SoeOptional}, {sfAuditorEncryptionKey, SoeOptional}, diff --git a/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro.ref b/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro.ref index c88bbc16..c9d8c4fa 100644 --- a/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro.ref +++ b/Tests/Xrpl.Tests/Fixtures/ledger_entries.macro.ref @@ -1,11 +1,11 @@ https://github.com/XRPLF/rippled/blob/develop/include/xrpl/protocol/detail/ledger_entries.macro -sha ecdd457f3598c7286a9af4aff358fbd30039173f -date 2026-07-30T23:04:38Z +sha 9859e5cedaffce2f9544d7e2b6aa0e041c3a6f75 +date 2026-08-07T15:00:25Z ledger_entries.macro is vendored byte-identical to the ref above so that it can be re-verified with a plain diff: - curl -sSL https://raw.githubusercontent.com/XRPLF/rippled/ecdd457f3598c7286a9af4aff358fbd30039173f/include/xrpl/protocol/detail/ledger_entries.macro \ + curl -sSL https://raw.githubusercontent.com/XRPLF/rippled/9859e5cedaffce2f9544d7e2b6aa0e041c3a6f75/include/xrpl/protocol/detail/ledger_entries.macro \ | diff - Tests/Xrpl.Tests/Fixtures/ledger_entries.macro This is the only place the protocol states which fields belong to which ledger @@ -13,7 +13,7 @@ object: definitions.json carries field codes and object types, but not the per-object field lists. Pinned to a develop commit rather than to a release tag — unlike LedgerFormats.h, -which is pinned to 3.3.0-rc1 because the two are identical there. The models track +which is pinned to the 3.3.0 tag. The models track develop for fields: sfLEVersion (Vault) exists only after 07/30/2026 and is absent from 3.3.0-rc1, so a tag would report it as a field the models invented. This sha is the one protocol-watch recorded when it reported the change. diff --git a/Tests/Xrpl.Tests/Fixtures/transactions.macro b/Tests/Xrpl.Tests/Fixtures/transactions.macro index e805596c..1f9603db 100644 --- a/Tests/Xrpl.Tests/Fixtures/transactions.macro +++ b/Tests/Xrpl.Tests/Fixtures/transactions.macro @@ -705,7 +705,7 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_CREATE, 54, MPTokenIssuanceCreate, {sfMaximumAmount, SoeOptional}, {sfMPTokenMetadata, SoeOptional}, {sfDomainID, SoeOptional}, - {sfMutableFlags, SoeOptional}, + {sfImmutableFlags, SoeOptional}, })) /** This transaction type destroys a MPTokensIssuance instance */ @@ -734,7 +734,7 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_SET, 56, MPTokenIssuanceSet, {sfDomainID, SoeOptional}, {sfMPTokenMetadata, SoeOptional}, {sfTransferFee, SoeOptional}, - {sfMutableFlags, SoeOptional}, + {sfImmutableFlags, SoeOptional}, {sfIssuerEncryptionKey, SoeOptional}, {sfAuditorEncryptionKey, SoeOptional}, })) @@ -1085,7 +1085,7 @@ TRANSACTION(ttLOAN_PAY, 84, LoanPay, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT, 85, ConfidentialMPTConvert, - Delegation::Delegable, + Delegation::NotDelegable, featureConfidentialTransfer, NoPriv, ({ @@ -1189,9 +1189,9 @@ TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, ({ {sfCounterpartySponsor, SoeOptional}, {sfSponsee, SoeOptional}, - {sfFeeAmount, SoeOptional}, + {sfFeeAmountDelta, SoeOptional}, {sfMaxFee, SoeOptional}, - {sfRemainingOwnerCount, SoeOptional}, + {sfRemainingOwnerCountDelta, SoeOptional}, })) /** This system-generated transaction type is used to update the status of the various amendments. diff --git a/Tests/Xrpl.Tests/Fixtures/transactions.macro.ref b/Tests/Xrpl.Tests/Fixtures/transactions.macro.ref index ff6ce6ed..83256926 100644 --- a/Tests/Xrpl.Tests/Fixtures/transactions.macro.ref +++ b/Tests/Xrpl.Tests/Fixtures/transactions.macro.ref @@ -1,11 +1,12 @@ -https://github.com/XRPLF/rippled/blob/develop/include/xrpl/protocol/detail/transactions.macro -sha fd2cc6dcb308fb811960380eba7bf4309934d05d -date 2026-07-10T21:58:19Z +https://github.com/XRPLF/rippled/blob/3.3.0/include/xrpl/protocol/detail/transactions.macro +sha 00a178fb92ca49521b937ae1a99d863765ea8a90 +date 2026-08-06T16:34:39Z +tag 3.3.0 transactions.macro is vendored byte-identical to the ref above so that it can be re-verified with a plain diff: - curl -sSL https://raw.githubusercontent.com/XRPLF/rippled/fd2cc6dcb308fb811960380eba7bf4309934d05d/include/xrpl/protocol/detail/transactions.macro \ + curl -sSL https://raw.githubusercontent.com/XRPLF/rippled/00a178fb92ca49521b937ae1a99d863765ea8a90/include/xrpl/protocol/detail/transactions.macro \ | diff - Tests/Xrpl.Tests/Fixtures/transactions.macro Do not hand-edit it. When protocol-watch reports a change to this file upstream, diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs index 2870e6e9..50bbbcf7 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs @@ -71,8 +71,8 @@ private static async Task SponsorshipSetAsync(XrplWallet sponsor, XrplWallet spo { Account = sponsor.ClassicAddress, Sponsee = sponsee.ClassicAddress, - FeeAmount = new Currency { ValueAsXrp = 10m }, - RemainingOwnerCount = 3, + FeeAmountDelta = new Currency { ValueAsXrp = 10m }, + RemainingOwnerCountDelta = 3, }; setup = await client.Autofill(setup); ValidateResult(await client.SubmitAndWait(setup, sponsor, autofill: false)); diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIDynamicMPT.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIDynamicMPT.cs index 0256d7af..e0e1c888 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIDynamicMPT.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIDynamicMPT.cs @@ -13,11 +13,12 @@ namespace XrplTests.Xrpl.ClientLib.Integration; /// -/// DynamicMPT (XLS-94) end-to-end coverage: an issuance declares at creation -/// which capabilities and fields may change later (MutableFlags), and a -/// later MPTokenIssuanceSet performs the mutation. Both directions are checked -/// against the ledger object, plus the permission rule that makes the feature -/// meaningful — a mutation the issuance never allowed is rejected. +/// DynamicMPT (XLS-94) end-to-end coverage: capabilities and fields of an +/// issuance stay mutable unless the issuer freezes them via ImmutableFlags, +/// and a later MPTokenIssuanceSet either performs the mutation, enables a +/// capability through a tfMPTSet* flag, or freezes more of the issuance. Both +/// directions are checked against the ledger object, plus the permission rule +/// that makes the feature meaningful — mutating a frozen field is rejected. /// /// Amendment-gated: DynamicMPT is Supported::No on rippled 3.2.x, so these /// tests skip on the CI stand and run for real on the nightly stand, where @@ -58,22 +59,22 @@ public void CheckAmendment() public static void ClassCleanup() => client?.Dispose(); [TestMethod] - public async Task TestDynamicMPT_MutableFlagsOnCreate_ReachTheLedgerObject() + public async Task TestDynamicMPT_ImmutableFlagsOnCreate_ReachTheLedgerObject() { XrplWallet issuer = XrplWallet.Generate(); await IntegrationTestConfig.TryFundWalletAsync(client, issuer, nodeType); - MPTokenIssuanceCreateMutableFlags mutable = - MPTokenIssuanceCreateMutableFlags.tmfMPTCanMutateMetadata | - MPTokenIssuanceCreateMutableFlags.tmfMPTCanMutateTransferFee | - MPTokenIssuanceCreateMutableFlags.tmfMPTCanEnableCanLock; + MPTokenIssuanceImmutableFlags immutable = + MPTokenIssuanceImmutableFlags.tifMPTMetadata | + MPTokenIssuanceImmutableFlags.tifMPTTransferFee | + MPTokenIssuanceImmutableFlags.tifMPTCanLock; - string issuanceId = await CreateIssuance(issuer, MPTokenIssuanceCreateFlags.tfMPTCanTransfer, mutable, InitialMetadata); + string issuanceId = await CreateIssuance(issuer, MPTokenIssuanceCreateFlags.tfMPTCanTransfer, immutable, InitialMetadata); LOMPTokenIssuance issuance = await ReadIssuance(issuanceId); - Assert.IsNotNull(issuance.MutableFlags, "MutableFlags should be present on the issuance"); - Assert.AreEqual((uint)mutable, issuance.MutableFlags.Value, "MutableFlags should round-trip unchanged"); + Assert.IsNotNull(issuance.ImmutableFlags, "ImmutableFlags should be present on the issuance"); + Assert.AreEqual((uint)immutable, issuance.ImmutableFlags.Value, "ImmutableFlags should round-trip unchanged"); Assert.AreEqual(InitialMetadata, issuance.MPTokenMetadata, "MPTokenMetadata should round-trip unchanged"); } @@ -83,13 +84,13 @@ public async Task TestDynamicMPT_MutateTransferFeeAndMetadata() XrplWallet issuer = XrplWallet.Generate(); await IntegrationTestConfig.TryFundWalletAsync(client, issuer, nodeType); - // TransferFee > 0 requires lsfMPTCanTransfer to be already set on the - // issuance (rippled MPTokenIssuanceSet::preclaim), hence tfMPTCanTransfer here. + // No ImmutableFlags: with DynamicMPT everything the amendment covers stays + // mutable by default. A TransferFee above zero still requires lsfMPTCanTransfer + // on the issuance (rippled MPTokenIssuanceSet::preclaim), hence tfMPTCanTransfer. string issuanceId = await CreateIssuance( issuer, MPTokenIssuanceCreateFlags.tfMPTCanTransfer, - MPTokenIssuanceCreateMutableFlags.tmfMPTCanMutateMetadata | - MPTokenIssuanceCreateMutableFlags.tmfMPTCanMutateTransferFee, + null, InitialMetadata); MPTokenIssuanceSet mutation = new MPTokenIssuanceSet @@ -108,28 +109,21 @@ public async Task TestDynamicMPT_MutateTransferFeeAndMetadata() Assert.AreEqual((ushort)500, issuance.TransferFee, "TransferFee should be the mutated value"); Assert.AreEqual(UpdatedMetadata, issuance.MPTokenMetadata, "MPTokenMetadata should be the mutated value"); - // doApply writes lsf flags, TransferFee and metadata; it never rewrites - // sfMutableFlags, so the permission set survives the mutation - Assert.IsNotNull(issuance.MutableFlags, "MutableFlags should still be present after a mutation"); - Assert.AreEqual( - (uint)(MPTokenIssuanceCreateMutableFlags.tmfMPTCanMutateMetadata | - MPTokenIssuanceCreateMutableFlags.tmfMPTCanMutateTransferFee), - issuance.MutableFlags.Value, - "MutableFlags should be unchanged by a mutation"); + // sfImmutableFlags is soeDEFAULT and this mutation never writes it, so the + // issuance stays fully mutable + Assert.IsTrue( + issuance.ImmutableFlags is null or 0u, + "ImmutableFlags should stay unset when the mutation does not freeze anything"); } [TestMethod] - public async Task TestDynamicMPT_SetMutableFlag_EnablesCapabilityOnTheIssuance() + public async Task TestDynamicMPT_SetCapabilityFlag_EnablesCapabilityOnTheIssuance() { XrplWallet issuer = XrplWallet.Generate(); await IntegrationTestConfig.TryFundWalletAsync(client, issuer, nodeType); - // Created WITHOUT tfMPTCanLock — only with the permission to enable it later - string issuanceId = await CreateIssuance( - issuer, - null, - MPTokenIssuanceCreateMutableFlags.tmfMPTCanEnableCanLock, - null); + // Created WITHOUT tfMPTCanLock and without freezing it, so it may be enabled later + string issuanceId = await CreateIssuance(issuer, null, null, null); LOMPTokenIssuance before = await ReadIssuance(issuanceId); Assert.IsTrue( @@ -140,7 +134,7 @@ public async Task TestDynamicMPT_SetMutableFlag_EnablesCapabilityOnTheIssuance() { Account = issuer.ClassicAddress, MPTokenIssuanceID = issuanceId, - MutableFlags = MPTokenIssuanceSetMutableFlags.tmfMPTSetCanLock, + Flags = MPTokenIssuanceSetFlags.tfMPTSetCanLock, }; enable = await client.Autofill(enable); TransactionSummary result = await client.SubmitAndWait(enable, issuer, true); @@ -149,17 +143,21 @@ public async Task TestDynamicMPT_SetMutableFlag_EnablesCapabilityOnTheIssuance() LOMPTokenIssuance after = await ReadIssuance(issuanceId); Assert.IsTrue( (after.Flags.GetValueOrDefault() & MPTokenIssuanceFlags.MPTCanLock) != 0, - "MPTCanLock should be set after tmfMPTSetCanLock"); + "MPTCanLock should be set after tfMPTSetCanLock"); } [TestMethod] - public async Task TestDynamicMPT_MutationWithoutPermission_IsRejected() + public async Task TestDynamicMPT_MutationOfFrozenField_IsRejected() { XrplWallet issuer = XrplWallet.Generate(); await IntegrationTestConfig.TryFundWalletAsync(client, issuer, nodeType); - // No MutableFlags at all: nothing about this issuance may be changed later - string issuanceId = await CreateIssuance(issuer, null, null, InitialMetadata); + // Metadata frozen at creation: no later transaction may rewrite it + string issuanceId = await CreateIssuance( + issuer, + null, + MPTokenIssuanceImmutableFlags.tifMPTMetadata, + InitialMetadata); MPTokenIssuanceSet mutation = new MPTokenIssuanceSet { @@ -177,17 +175,56 @@ await Helper.ThrowsExceptionAsync( Assert.AreEqual(InitialMetadata, issuance.MPTokenMetadata, "MPTokenMetadata should be untouched by the rejected mutation"); } + [TestMethod] + public async Task TestDynamicMPT_FreezeViaSet_BlocksTheNextMutation() + { + XrplWallet issuer = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletAsync(client, issuer, nodeType); + + // Created fully mutable, then frozen by a separate MPTokenIssuanceSet: + // doApply ORs ImmutableFlags into the ledger object, so a freeze is one-way + string issuanceId = await CreateIssuance(issuer, null, null, InitialMetadata); + + MPTokenIssuanceSet freeze = new MPTokenIssuanceSet + { + Account = issuer.ClassicAddress, + MPTokenIssuanceID = issuanceId, + ImmutableFlags = MPTokenIssuanceImmutableFlags.tifMPTMetadata, + }; + freeze = await client.Autofill(freeze); + ValidateResult(await client.SubmitAndWait(freeze, issuer, true)); + + LOMPTokenIssuance frozen = await ReadIssuance(issuanceId); + Assert.IsNotNull(frozen.ImmutableFlags, "ImmutableFlags should be present after the freeze"); + Assert.AreEqual( + (uint)MPTokenIssuanceImmutableFlags.tifMPTMetadata, + frozen.ImmutableFlags.Value, + "ImmutableFlags should carry the freshly frozen bit"); + + MPTokenIssuanceSet mutation = new MPTokenIssuanceSet + { + Account = issuer.ClassicAddress, + MPTokenIssuanceID = issuanceId, + MPTokenMetadata = UpdatedMetadata, + }; + mutation = await client.Autofill(mutation); + + await Helper.ThrowsExceptionAsync( + () => client.SubmitAndWait(mutation, issuer, true), + "Final tx result is not success: tecNO_PERMISSION"); + } + private static async Task CreateIssuance( XrplWallet issuer, MPTokenIssuanceCreateFlags? flags, - MPTokenIssuanceCreateMutableFlags? mutableFlags, + MPTokenIssuanceImmutableFlags? immutableFlags, string metadata) { MPTokenIssuanceCreate create = new MPTokenIssuanceCreate { Account = issuer.ClassicAddress, Flags = flags, - MutableFlags = mutableFlags, + ImmutableFlags = immutableFlags, MPTokenMetadata = metadata, }; create = await client.Autofill(create); diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs b/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs index 4f359004..fb4ec55e 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs @@ -74,8 +74,8 @@ public async Task TestSponsorshipSet_BySponsor_CreatesLedgerObject() { Account = sponsor.ClassicAddress, Sponsee = sponsee.ClassicAddress, - FeeAmount = new Currency { ValueAsXrp = 5m }, - RemainingOwnerCount = 3, + FeeAmountDelta = new Currency { ValueAsXrp = 5m }, + RemainingOwnerCountDelta = 3, }; tx = await client.Autofill(tx); @@ -89,6 +89,42 @@ public async Task TestSponsorshipSet_BySponsor_CreatesLedgerObject() Assert.AreEqual((uint)3, sponsorship.RemainingOwnerCount); } + [TestMethod] + public async Task TestSponsorshipSet_NegativeDeltas_ReduceTheBudget() + { + XrplWallet sponsor = XrplWallet.Generate(); + XrplWallet sponsee = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, sponsor, sponsee); + + SponsorshipSet create = new SponsorshipSet + { + Account = sponsor.ClassicAddress, + Sponsee = sponsee.ClassicAddress, + FeeAmountDelta = new Currency { ValueAsXrp = 5m }, + RemainingOwnerCountDelta = 3, + }; + create = await client.Autofill(create); + ValidateResult(await client.SubmitAndWait(create, sponsor, true)); + + // Since 3.3.0 the transaction carries signed deltas rather than absolute values: + // rippled adds them to what the Sponsorship object already holds, moving the XRP + // back to the sponsor balance for a negative FeeAmountDelta + SponsorshipSet reduce = new SponsorshipSet + { + Account = sponsor.ClassicAddress, + Sponsee = sponsee.ClassicAddress, + FeeAmountDelta = new Currency { ValueAsXrp = -2m }, + RemainingOwnerCountDelta = -1, + }; + reduce = await client.Autofill(reduce); + ValidateResult(await client.SubmitAndWait(reduce, sponsor, true)); + + LOSponsorship sponsorship = await GetSponsorshipObject(sponsor.ClassicAddress); + Assert.IsNotNull(sponsorship, "Sponsorship ledger object should still exist after the reduction"); + Assert.AreEqual(3m, sponsorship.FeeAmount.ValueAsXrp, "FeeAmount should be 5 XRP + (-2 XRP)"); + Assert.AreEqual((uint)2, sponsorship.RemainingOwnerCount, "RemainingOwnerCount should be 3 + (-1)"); + } + [TestMethod] public async Task TestSponsoredPayment_SponsorPaysFee() { @@ -102,7 +138,7 @@ public async Task TestSponsoredPayment_SponsorPaysFee() { Account = sponsor.ClassicAddress, Sponsee = sponsee.ClassicAddress, - FeeAmount = new Currency { ValueAsXrp = 5m }, + FeeAmountDelta = new Currency { ValueAsXrp = 5m }, }; setup = await client.Autofill(setup); ValidateResult(await client.SubmitAndWait(setup, sponsor, true)); @@ -139,7 +175,7 @@ public async Task TestSponsoredPayment_SponsorPaysFee() { Account = sponsor.ClassicAddress, Sponsee = sponsee.ClassicAddress, - FeeAmount = new Currency { ValueAsXrp = 5m }, + FeeAmountDelta = new Currency { ValueAsXrp = 5m }, Flags = flags, }; setup = await client.Autofill(setup); @@ -256,7 +292,7 @@ public async Task TestSponsorshipSet_DeleteObject() { Account = sponsor.ClassicAddress, Sponsee = sponsee.ClassicAddress, - FeeAmount = new Currency { ValueAsXrp = 2m }, + FeeAmountDelta = new Currency { ValueAsXrp = 2m }, }; create = await client.Autofill(create); ValidateResult(await client.SubmitAndWait(create, sponsor, true)); diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestISponsorshipSigningMatrix.cs b/Tests/Xrpl.Tests/Integration/transactions/TestISponsorshipSigningMatrix.cs index 6a90ad5f..54127acc 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestISponsorshipSigningMatrix.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestISponsorshipSigningMatrix.cs @@ -75,7 +75,7 @@ await SubmitTesAsync(new SponsorshipSet { Account = sponsor.ClassicAddress, Sponsee = sponsee.ClassicAddress, - FeeAmount = new Currency { ValueAsXrp = 10m }, + FeeAmountDelta = new Currency { ValueAsXrp = 10m }, }, sponsor); return (sponsor, sponsee, destination); } diff --git a/Tests/Xrpl.Tests/Models/RippledLedgerFlags.cs b/Tests/Xrpl.Tests/Models/RippledLedgerFlags.cs index d7614637..f3cada13 100644 --- a/Tests/Xrpl.Tests/Models/RippledLedgerFlags.cs +++ b/Tests/Xrpl.Tests/Models/RippledLedgerFlags.cs @@ -31,6 +31,22 @@ internal static class RippledLedgerFlags @"LSF_FLAG2?\(\s*(?ls[fm]\w+)\s*,\s*(?0x[0-9a-fA-F]+)\s*\)", RegexOptions.Compiled); + /// + /// MPTokenIssuance ImmutableFlags do not live in a LEDGER_OBJECT block: since 3.3.0 + /// rippled declares them as plain constants next to the macro list + /// (inline constexpr std::uint32_t lsifMPTCanLock = 0x00000002;). They are the + /// values of sfImmutableFlags, so the models still have to conform to them. + /// + private static readonly Regex ImmutableFlagConstant = new Regex( + @"inline\s+constexpr\s+std::uint32_t\s+(?lsif\w+)\s*=\s*(?0x[0-9a-fA-F]+)\s*;", + RegexOptions.Compiled); + + /// + /// The synthetic object name the lsif* constants are reported under, so they take part + /// in the same conformance check as the LEDGER_OBJECT flags. + /// + internal const string ImmutableFlagsObject = "MPTokenIssuanceImmutable"; + /// /// Catches an LSF_FLAG variant the parser does not know yet — a new macro name would /// otherwise drop its flags silently and leave the conformance test passing. @@ -110,6 +126,25 @@ internal static Dictionary> Parse() flagCount += flags.Count; } + Dictionary immutableFlags = new(); + foreach (Match constant in ImmutableFlagConstant.Matches(header)) + { + immutableFlags[constant.Groups["flag"].Value] = + Convert.ToUInt32(constant.Groups["value"].Value.Substring(2), 16); + } + + // Fail closed: the constants moved out of LEDGER_OBJECT in 3.3.0 and could move + // again, which would drop MPTokenIssuanceImmutable from the guard without a word. + if (immutableFlags.Count == 0) + { + throw new InvalidOperationException( + "No lsif* constants found in LedgerFormats.h — sfImmutableFlags values are no " + + "longer declared the way the parser expects, update it before trusting this test"); + } + + objects.Add(ImmutableFlagsObject, immutableFlags); + flagCount += immutableFlags.Count; + if (objects.Count < MinimumExpectedObjects || flagCount < MinimumExpectedFlags) { throw new InvalidOperationException( diff --git a/Tests/Xrpl.Tests/Models/TestUConfidentialMPT.cs b/Tests/Xrpl.Tests/Models/TestUConfidentialMPT.cs index ed8762ad..7ef287a6 100644 --- a/Tests/Xrpl.Tests/Models/TestUConfidentialMPT.cs +++ b/Tests/Xrpl.Tests/Models/TestUConfidentialMPT.cs @@ -142,14 +142,14 @@ public void TestUSponsorship_BinaryRoundTrip() { Account = Account1, Sponsee = Account2, - FeeAmount = new global::Xrpl.Models.Common.Currency { ValueAsXrp = 5m }, - RemainingOwnerCount = 3, + FeeAmountDelta = new global::Xrpl.Models.Common.Currency { ValueAsXrp = 5m }, + RemainingOwnerCountDelta = 3, Flags = SponsorshipSetFlags.tfSponsorshipSetRequireSignForFee, }; JsonObject decoded = RoundTrip(tx); Assert.AreEqual("SponsorshipSet", decoded["TransactionType"]!.GetValue()); Assert.AreEqual(Account2, decoded["Sponsee"]!.GetValue()); - Assert.AreEqual(3u, decoded["RemainingOwnerCount"]!.GetValue()); + Assert.AreEqual(3, decoded["RemainingOwnerCountDelta"]!.GetValue()); Assert.AreEqual((uint)SponsorshipSetFlags.tfSponsorshipSetRequireSignForFee, decoded["Flags"]!.GetValue()); } diff --git a/Tests/Xrpl.Tests/Models/TestULedgerFlagsConformance.cs b/Tests/Xrpl.Tests/Models/TestULedgerFlagsConformance.cs index 569b47ac..af3b3d96 100644 --- a/Tests/Xrpl.Tests/Models/TestULedgerFlagsConformance.cs +++ b/Tests/Xrpl.Tests/Models/TestULedgerFlagsConformance.cs @@ -44,9 +44,10 @@ public class TestULedgerFlagsConformance ["DirNode"] = typeof(DirectoryNodeFlags), ["NFTokenOffer"] = typeof(NFTokenOffer), ["MPTokenIssuance"] = typeof(MPTokenIssuanceFlags), - // rippled declares these as lsmf* ledger flags; TxFlags.h then aliases tmfX = lsmfX, - // and the SDK names them after the transaction side (MPTokenIssuanceCreate.MutableFlags) - ["MPTokenIssuanceMutable"] = typeof(MPTokenIssuanceCreateMutableFlags), + // rippled declares these as lsif* constants rather than a LEDGER_OBJECT block; + // TxFlags.h then aliases tifX = lsifX, and the SDK shares one enum between + // MPTokenIssuanceCreate.ImmutableFlags and MPTokenIssuanceSet.ImmutableFlags + [RippledLedgerFlags.ImmutableFlagsObject] = typeof(MPTokenIssuanceImmutableFlags), ["MPToken"] = typeof(MPTokenFlags), ["Credential"] = typeof(CredentialFlags), ["Vault"] = typeof(VaultLedgerFlags), @@ -56,11 +57,11 @@ public class TestULedgerFlagsConformance /// /// Strips the prefix rippled and the models use for the same bit, so - /// lsfMPTLocked, MPTLocked and tmfMPTCanEnableCanLock compare + /// lsfMPTLocked, MPTLocked and tifMPTCanLock compare /// against their upstream counterparts. /// private static string Normalize(string name) => - Regex.Replace(name, "^(lsmf|lsf|tmf)", string.Empty); + Regex.Replace(name, "^(lsmf|lsif|lsf|tmf|tif)", string.Empty); [TestMethod] public void TestULedgerFlags_MatchRippledLedgerFormats() diff --git a/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs b/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs index 1a27dbcc..2195911f 100644 --- a/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs +++ b/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs @@ -80,7 +80,8 @@ public void TestUMPTokenIssuanceSet_DynamicFields_RoundTrip() { Account = Account1, MPTokenIssuanceID = "00000001A407AF5856CCF3C42619DAA925813FC955C72983", - MutableFlags = MPTokenIssuanceSetMutableFlags.tmfMPTSetCanLock | MPTokenIssuanceSetMutableFlags.tmfMPTSetRequireAuth, + Flags = MPTokenIssuanceSetFlags.tfMPTSetCanLock | MPTokenIssuanceSetFlags.tfMPTSetRequireAuth, + ImmutableFlags = MPTokenIssuanceImmutableFlags.tifMPTCanTrade | MPTokenIssuanceImmutableFlags.tifMPTMetadata, TransferFee = 250, MPTokenMetadata = "DEADBEEF", DomainID = new string('B', 64), @@ -94,7 +95,12 @@ public void TestUMPTokenIssuanceSet_DynamicFields_RoundTrip() string blob = XrplBinaryCodec.Encode(json); JsonObject decoded = XrplBinaryCodec.Decode(blob).AsObject(); - Assert.AreEqual(3u, decoded["MutableFlags"]!.GetValue()); + Assert.AreEqual( + (uint)(MPTokenIssuanceSetFlags.tfMPTSetCanLock | MPTokenIssuanceSetFlags.tfMPTSetRequireAuth), + decoded["Flags"]!.GetValue()); + Assert.AreEqual( + (uint)(MPTokenIssuanceImmutableFlags.tifMPTCanTrade | MPTokenIssuanceImmutableFlags.tifMPTMetadata), + decoded["ImmutableFlags"]!.GetValue()); Assert.AreEqual(250u, decoded["TransferFee"]!.GetValue()); Assert.AreEqual("DEADBEEF", decoded["MPTokenMetadata"]!.GetValue()); Assert.AreEqual(new string('B', 64), decoded["DomainID"]!.GetValue()); @@ -199,14 +205,19 @@ public async Task TestUMPTokenIssuanceSet_PreflightRules() ["MPTokenIssuanceID"] = "00000001A407AF5856CCF3C42619DAA925813FC955C72983", }; - // MutableFlags: zero and out-of-mask values are temINVALID_FLAG - tx["MutableFlags"] = 0u; + // ImmutableFlags: zero and out-of-mask values are temINVALID_FLAG + tx["ImmutableFlags"] = 0u; await Assert.ThrowsExactlyAsync(() => Validation.ValidateMPTokenIssuanceSet(tx)); - tx["MutableFlags"] = 0x80u; + tx["ImmutableFlags"] = 0x1u; // outside tif* mask (0x2..0x80, 0x10000, 0x20000) await Assert.ThrowsExactlyAsync(() => Validation.ValidateMPTokenIssuanceSet(tx)); - // Non-zero TransferFee combined with enabling confidential balances is temBAD_TRANSFER_FEE - tx["MutableFlags"] = (uint)MPTokenIssuanceSetMutableFlags.tmfMPTSetCanHoldConfidentialBalance; + tx["ImmutableFlags"] = (uint)MPTokenIssuanceImmutableFlags.tifMPTCanHoldConfidentialBalance; + await Validation.ValidateMPTokenIssuanceSet(tx); + + // Non-zero TransferFee combined with enabling confidential balances is temBAD_TRANSFER_FEE. + // Since 3.3.0 the capability is enabled through a tf* flag, not through a separate field. + tx.Remove("ImmutableFlags"); + tx["Flags"] = (uint)MPTokenIssuanceSetFlags.tfMPTSetCanHoldConfidentialBalance; tx["TransferFee"] = 10u; await Assert.ThrowsExactlyAsync(() => Validation.ValidateMPTokenIssuanceSet(tx)); @@ -215,7 +226,7 @@ public async Task TestUMPTokenIssuanceSet_PreflightRules() } [TestMethod] - public async Task TestUMPTokenIssuanceCreate_MutableFlagsMask() + public async Task TestUMPTokenIssuanceCreate_ImmutableFlagsMask() { Dictionary tx = new() { @@ -223,12 +234,12 @@ public async Task TestUMPTokenIssuanceCreate_MutableFlagsMask() ["Account"] = Account1, }; - tx["MutableFlags"] = 0u; + tx["ImmutableFlags"] = 0u; await Assert.ThrowsExactlyAsync(() => Validation.ValidateMPTokenIssuanceCreate(tx)); - tx["MutableFlags"] = 0x100u; // outside tmf* mask + tx["ImmutableFlags"] = 0x100u; // outside tif* mask await Assert.ThrowsExactlyAsync(() => Validation.ValidateMPTokenIssuanceCreate(tx)); - tx["MutableFlags"] = (uint)(MPTokenIssuanceCreateMutableFlags.tmfMPTCanMutateMetadata | MPTokenIssuanceCreateMutableFlags.tmfMPTCanMutateTransferFee); + tx["ImmutableFlags"] = (uint)(MPTokenIssuanceImmutableFlags.tifMPTMetadata | MPTokenIssuanceImmutableFlags.tifMPTTransferFee); await Validation.ValidateMPTokenIssuanceCreate(tx); tx["DomainID"] = 12345; diff --git a/Xrpl/Models/Ledger/LOMPTokenIssuance.cs b/Xrpl/Models/Ledger/LOMPTokenIssuance.cs index 75689ef3..d01a39af 100644 --- a/Xrpl/Models/Ledger/LOMPTokenIssuance.cs +++ b/Xrpl/Models/Ledger/LOMPTokenIssuance.cs @@ -173,10 +173,10 @@ public LOMPTokenIssuance() public string? DomainID { get; init; } /// - /// DynamicMPT: which issuance flags remain mutable. + /// DynamicMPT: which issuance capabilities and fields are frozen. /// - [JsonPropertyName("MutableFlags")] - public uint? MutableFlags { get; init; } + [JsonPropertyName("ImmutableFlags")] + public uint? ImmutableFlags { get; init; } /// /// MPTokensV2: the reference holding object for DEX trading. diff --git a/Xrpl/Models/Transactions/Common.cs b/Xrpl/Models/Transactions/Common.cs index 48b300c4..f55c6b4a 100644 --- a/Xrpl/Models/Transactions/Common.cs +++ b/Xrpl/Models/Transactions/Common.cs @@ -171,6 +171,23 @@ public static bool TryGetUInt32(object value, out uint result) } } + /// + /// Extracts a signed Int32 from any integral representation produced by the JSON layer. + /// Used by delta fields such as SponsorshipSet.RemainingOwnerCountDelta, which are + /// serialized as the XRPL Int32 type and may be negative. + /// + public static bool TryGetInt32(object value, out int result) + { + switch (value) + { + case int i: result = i; return true; + case uint u when u <= int.MaxValue: result = (int)u; return true; + case long l when l >= int.MinValue && l <= int.MaxValue: result = (int)l; return true; + case ulong ul when ul <= int.MaxValue: result = (int)ul; return true; + default: result = 0; return false; + } + } + /// /// Validates a flags value that must be non-zero and contain only bits /// defined by (rippled temINVALID_FLAG pattern). diff --git a/Xrpl/Models/Transactions/MPTokenIssuanceCreate.cs b/Xrpl/Models/Transactions/MPTokenIssuanceCreate.cs index d97e0ab7..b4bd61b4 100644 --- a/Xrpl/Models/Transactions/MPTokenIssuanceCreate.cs +++ b/Xrpl/Models/Transactions/MPTokenIssuanceCreate.cs @@ -50,42 +50,6 @@ public enum MPTokenIssuanceCreateFlags : uint tfMPTCanClawback = 64 } - /// - /// DynamicMPT (XLS-94): MutableFlags values for MPTokenIssuanceCreate — - /// which capabilities/fields may be changed after issuance. - /// Values mirror rippled TxFlags.h (tmf* = lsmf* ledger flags). - /// - [Flags] - public enum MPTokenIssuanceCreateMutableFlags : uint - { - /// Allow enabling lsfMPTCanLock after issuance. - tmfMPTCanEnableCanLock = 0x00000002, - - /// Allow enabling lsfMPTRequireAuth after issuance. - tmfMPTCanEnableRequireAuth = 0x00000004, - - /// Allow enabling lsfMPTCanEscrow after issuance. - tmfMPTCanEnableCanEscrow = 0x00000008, - - /// Allow enabling lsfMPTCanTrade after issuance. - tmfMPTCanEnableCanTrade = 0x00000010, - - /// Allow enabling lsfMPTCanTransfer after issuance. - tmfMPTCanEnableCanTransfer = 0x00000020, - - /// Allow enabling lsfMPTCanClawback after issuance. - tmfMPTCanEnableCanClawback = 0x00000040, - - /// Forbid enabling confidential balances (ConfidentialTransfer) after issuance. - tmfMPTCannotEnableCanHoldConfidentialBalance = 0x00000080, - - /// Allow mutating MPTokenMetadata after issuance. - tmfMPTCanMutateMetadata = 0x00010000, - - /// Allow mutating TransferFee after issuance. - tmfMPTCanMutateTransferFee = 0x00020000, - } - /// /// The MPTokenIssuanceCreate transaction creates an MPTokenIssuance object /// and adds it to the relevant directory node of the creator account. @@ -134,8 +98,8 @@ public interface IMPTokenIssuanceCreate : ITransactionCommon set => MPTokenMetadata = value?.ToHex(); } - /// DynamicMPT: which issuance flags remain mutable after creation. - public MPTokenIssuanceCreateMutableFlags? MutableFlags { get; set; } + /// DynamicMPT: which issuance capabilities and fields are frozen at creation. + public MPTokenIssuanceImmutableFlags? ImmutableFlags { get; set; } /// PermissionedDomains: domain restricting who may hold this MPT. public string DomainID { get; set; } @@ -191,8 +155,8 @@ public MPTokenMetadataSchema? Metadata } /// - [JsonPropertyName("MutableFlags")] - public MPTokenIssuanceCreateMutableFlags? MutableFlags { get; set; } + [JsonPropertyName("ImmutableFlags")] + public MPTokenIssuanceImmutableFlags? ImmutableFlags { get; set; } /// [JsonPropertyName("DomainID")] @@ -251,8 +215,8 @@ public MPTokenMetadataSchema? Metadata } /// - [JsonPropertyName("MutableFlags")] - public MPTokenIssuanceCreateMutableFlags? MutableFlags { get; set; } + [JsonPropertyName("ImmutableFlags")] + public MPTokenIssuanceImmutableFlags? ImmutableFlags { get; set; } /// [JsonPropertyName("DomainID")] @@ -342,16 +306,16 @@ public static async Task ValidateMPTokenIssuanceCreate(Dictionary(mutable, "MPTokenIssuanceCreate: invalid MutableFlags"); + // set and only tif* bits are allowed (temINVALID_FLAG otherwise) + Common.ValidateNonZeroFlagsMask(immutable, "MPTokenIssuanceCreate: invalid ImmutableFlags"); } if (tx.TryGetValue("DomainID", out var domainId) && domainId is not null) diff --git a/Xrpl/Models/Transactions/MPTokenIssuanceImmutableFlags.cs b/Xrpl/Models/Transactions/MPTokenIssuanceImmutableFlags.cs new file mode 100644 index 00000000..3484ccea --- /dev/null +++ b/Xrpl/Models/Transactions/MPTokenIssuanceImmutableFlags.cs @@ -0,0 +1,52 @@ +#nullable enable +using System; + +namespace Xrpl.Models.Transactions +{ + /// + /// DynamicMPT (XLS-94): ImmutableFlags values shared by MPTokenIssuanceCreate + /// and MPTokenIssuanceSet — which capabilities and fields are frozen for the + /// lifetime of the issuance. + /// + /// A bit that is NOT set leaves the corresponding capability or field mutable, + /// so an issuance created without ImmutableFlags can be changed later. Bits are + /// only ever added: MPTokenIssuanceSet ORs the value into the ledger object + /// (rippled MPTokenIssuanceSet::doApply), it never clears a bit. + /// + /// Values mirror rippled TxFlags.h tif* constants, which alias the lsif* + /// ledger constants in LedgerFormats.h. + /// + [Flags] + public enum MPTokenIssuanceImmutableFlags : uint + { + /// lsfMPTCanLock may never be enabled or disabled after this transaction. + tifMPTCanLock = 0x00000002, + + /// lsfMPTRequireAuth may never be enabled or disabled after this transaction. + tifMPTRequireAuth = 0x00000004, + + /// lsfMPTCanEscrow may never be enabled or disabled after this transaction. + tifMPTCanEscrow = 0x00000008, + + /// lsfMPTCanTrade may never be enabled or disabled after this transaction. + tifMPTCanTrade = 0x00000010, + + /// lsfMPTCanTransfer may never be enabled or disabled after this transaction. + tifMPTCanTransfer = 0x00000020, + + /// lsfMPTCanClawback may never be enabled or disabled after this transaction. + tifMPTCanClawback = 0x00000040, + + /// + /// lsfMPTCanHoldConfidentialBalance may never be enabled after this transaction. + /// Requires the ConfidentialTransfer amendment. + /// + tifMPTCanHoldConfidentialBalance = 0x00000080, + + /// MPTokenMetadata may never be changed after this transaction. + tifMPTMetadata = 0x00010000, + + /// TransferFee may never be changed after this transaction. + tifMPTTransferFee = 0x00020000, + } +} diff --git a/Xrpl/Models/Transactions/MPTokenIssuanceSet.cs b/Xrpl/Models/Transactions/MPTokenIssuanceSet.cs index f73118b4..8e0e11e5 100644 --- a/Xrpl/Models/Transactions/MPTokenIssuanceSet.cs +++ b/Xrpl/Models/Transactions/MPTokenIssuanceSet.cs @@ -12,50 +12,44 @@ namespace Xrpl.Models.Transactions { /// - /// DynamicMPT (XLS-94): MutableFlags values for MPTokenIssuanceSet — - /// one-way enabling of capability flags (once enabled, cannot be disabled here). - /// Values mirror rippled TxFlags.h tmfMPTSet*. + /// Enum representing flags for MPTokenIssuanceSet transactions. /// [Flags] - public enum MPTokenIssuanceSetMutableFlags : uint + public enum MPTokenIssuanceSetFlags : uint { - /// Enable lsfMPTCanLock on the issuance. - tmfMPTSetCanLock = 0x00000001, + /// + /// If set, indicates that all MPT balances for this asset should be locked. + /// + tfMPTLock = 0x00000001, - /// Enable lsfMPTRequireAuth on the issuance. - tmfMPTSetRequireAuth = 0x00000002, + /// + /// If set, indicates that all MPT balances for this asset should be unlocked. + /// + tfMPTUnlock = 0x00000002, - /// Enable lsfMPTCanEscrow on the issuance. - tmfMPTSetCanEscrow = 0x00000004, + /// DynamicMPT: enable lsfMPTCanLock on the issuance. + tfMPTSetCanLock = 0x00000004, - /// Enable lsfMPTCanTrade on the issuance. - tmfMPTSetCanTrade = 0x00000008, + /// DynamicMPT: enable lsfMPTRequireAuth on the issuance. + tfMPTSetRequireAuth = 0x00000008, - /// Enable lsfMPTCanTransfer on the issuance. - tmfMPTSetCanTransfer = 0x00000010, + /// DynamicMPT: enable lsfMPTCanEscrow on the issuance. + tfMPTSetCanEscrow = 0x00000010, - /// Enable lsfMPTCanClawback on the issuance. - tmfMPTSetCanClawback = 0x00000020, + /// DynamicMPT: enable lsfMPTCanTrade on the issuance. + tfMPTSetCanTrade = 0x00000020, - /// Enable holding confidential balances (ConfidentialTransfer). - tmfMPTSetCanHoldConfidentialBalance = 0x00000040, - } + /// DynamicMPT: enable lsfMPTCanTransfer on the issuance. + tfMPTSetCanTransfer = 0x00000040, - /// - /// Enum representing flags for MPTokenIssuanceSet transactions. - /// - [Flags] - public enum MPTokenIssuanceSetFlags : uint - { - /// - /// If set, indicates that all MPT balances for this asset should be locked. - /// - tfMPTLock = 1, + /// DynamicMPT: enable lsfMPTCanClawback on the issuance. + tfMPTSetCanClawback = 0x00000080, /// - /// If set, indicates that all MPT balances for this asset should be unlocked. + /// DynamicMPT: enable lsfMPTCanHoldConfidentialBalance on the issuance. + /// Requires the ConfidentialTransfer amendment. /// - tfMPTUnlock = 2 + tfMPTSetCanHoldConfidentialBalance = 0x00000100 } /// @@ -76,13 +70,13 @@ public interface IMPTokenIssuanceSet : ITransactionCommon public string? Holder { get; set; } public new MPTokenIssuanceSetFlags? Flags { get; set; } - /// DynamicMPT: capability flags to enable on the issuance (one-way). - public MPTokenIssuanceSetMutableFlags? MutableFlags { get; set; } + /// DynamicMPT: capabilities and fields to freeze on the issuance (one-way). + public MPTokenIssuanceImmutableFlags? ImmutableFlags { get; set; } - /// DynamicMPT: new transfer fee (requires tfMPTCanMutateTransferFee). + /// DynamicMPT: new transfer fee (rejected once tifMPTTransferFee froze the field). public ushort? TransferFee { get; set; } - /// DynamicMPT: new metadata blob in hex (requires tfMPTCanMutateMetadata). + /// DynamicMPT: new metadata blob in hex (rejected once tifMPTMetadata froze the field). public string MPTokenMetadata { get; set; } /// PermissionedDomains: domain restricting who may hold this MPT. @@ -123,8 +117,8 @@ public MPTokenIssuanceSet() /// - [JsonPropertyName("MutableFlags")] - public MPTokenIssuanceSetMutableFlags? MutableFlags { get; set; } + [JsonPropertyName("ImmutableFlags")] + public MPTokenIssuanceImmutableFlags? ImmutableFlags { get; set; } /// [JsonPropertyName("TransferFee")] @@ -184,8 +178,8 @@ public class MPTokenIssuanceSetResponse : TransactionResponse, IMPTokenIssuanceS /// - [JsonPropertyName("MutableFlags")] - public MPTokenIssuanceSetMutableFlags? MutableFlags { get; set; } + [JsonPropertyName("ImmutableFlags")] + public MPTokenIssuanceImmutableFlags? ImmutableFlags { get; set; } /// [JsonPropertyName("TransferFee")] @@ -247,9 +241,10 @@ public static async Task ValidateMPTokenIssuanceSet(Dictionary t } } + uint flagValue = 0; if (tx.TryGetValue("Flags", out var flags) && flags is not null) { - uint flagValue = Convert.ToUInt32(flags); + flagValue = Convert.ToUInt32(flags); bool hasLock = (flagValue & (uint)MPTokenIssuanceSetFlags.tfMPTLock) != 0; bool hasUnlock = (flagValue & (uint)MPTokenIssuanceSetFlags.tfMPTUnlock) != 0; @@ -259,17 +254,16 @@ public static async Task ValidateMPTokenIssuanceSet(Dictionary t } } - uint mutable = 0; - if (tx.TryGetValue("MutableFlags", out var mutableFlags) && mutableFlags is not null) + if (tx.TryGetValue("ImmutableFlags", out var immutableFlags) && immutableFlags is not null) { - if (!Common.TryGetUInt32(mutableFlags, out mutable)) + if (!Common.TryGetUInt32(immutableFlags, out uint immutable)) { - throw new ValidationException("MPTokenIssuanceSet: MutableFlags must be a number"); + throw new ValidationException("MPTokenIssuanceSet: ImmutableFlags must be a number"); } // rippled MPTokenIssuanceSet::preflight: at least one flag must be - // set and only tmfMPTSet* bits are allowed (temINVALID_FLAG otherwise) - Common.ValidateNonZeroFlagsMask(mutable, "MPTokenIssuanceSet: invalid MutableFlags"); + // set and only tif* bits are allowed (temINVALID_FLAG otherwise) + Common.ValidateNonZeroFlagsMask(immutable, "MPTokenIssuanceSet: invalid ImmutableFlags"); } if (tx.TryGetValue("TransferFee", out var transferFee) && transferFee is not null) @@ -286,9 +280,9 @@ public static async Task ValidateMPTokenIssuanceSet(Dictionary t // rippled MPTokenIssuanceSet::preflight: a non-zero TransferFee combined // with enabling confidential balances is temBAD_TRANSFER_FEE - if (fee > 0 && (mutable & (uint)MPTokenIssuanceSetMutableFlags.tmfMPTSetCanHoldConfidentialBalance) != 0) + if (fee > 0 && (flagValue & (uint)MPTokenIssuanceSetFlags.tfMPTSetCanHoldConfidentialBalance) != 0) { - throw new ValidationException("MPTokenIssuanceSet: TransferFee must be 0 when tmfMPTSetCanHoldConfidentialBalance is set"); + throw new ValidationException("MPTokenIssuanceSet: TransferFee must be 0 when tfMPTSetCanHoldConfidentialBalance is set"); } } diff --git a/Xrpl/Models/Transactions/SponsorshipSet.cs b/Xrpl/Models/Transactions/SponsorshipSet.cs index 501fe240..d5ae4ff6 100644 --- a/Xrpl/Models/Transactions/SponsorshipSet.cs +++ b/Xrpl/Models/Transactions/SponsorshipSet.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Text.Json.Serialization; using System.Threading.Tasks; @@ -71,9 +72,11 @@ public interface ISponsorshipSet : ITransactionCommon string CounterpartySponsor { get; set; } /// - /// The amount of fees the sponsor commits to cover. + /// Signed change applied to the FeeAmount held by the Sponsorship object — + /// XRP the sponsor adds to (positive) or reclaims from (negative) the fee budget. + /// Must be a non-zero XRP amount, and positive when the object is being created. /// - Currency FeeAmount { get; set; } + Currency FeeAmountDelta { get; set; } /// /// The maximum fee per transaction the sponsor is willing to cover. @@ -81,9 +84,11 @@ public interface ISponsorshipSet : ITransactionCommon Currency MaxFee { get; set; } /// - /// The number of owner-reserve slots the sponsor commits to cover. + /// Signed change applied to the RemainingOwnerCount held by the Sponsorship + /// object — owner-reserve slots the sponsor adds (positive) or withdraws + /// (negative). Must be non-zero, and positive when the object is being created. /// - uint? RemainingOwnerCount { get; set; } + int? RemainingOwnerCountDelta { get; set; } } /// @@ -114,9 +119,9 @@ public SponsorshipSet() public string CounterpartySponsor { get; set; } /// - [JsonPropertyName("FeeAmount")] + [JsonPropertyName("FeeAmountDelta")] [JsonConverter(typeof(CurrencyConverter))] - public Currency FeeAmount { get; set; } + public Currency FeeAmountDelta { get; set; } /// [JsonPropertyName("MaxFee")] @@ -124,8 +129,8 @@ public SponsorshipSet() public Currency MaxFee { get; set; } /// - [JsonPropertyName("RemainingOwnerCount")] - public uint? RemainingOwnerCount { get; set; } + [JsonPropertyName("RemainingOwnerCountDelta")] + public int? RemainingOwnerCountDelta { get; set; } } /// @@ -151,9 +156,9 @@ public class SponsorshipSetResponse : TransactionResponse, ISponsorshipSet public string CounterpartySponsor { get; set; } /// - [JsonPropertyName("FeeAmount")] + [JsonPropertyName("FeeAmountDelta")] [JsonConverter(typeof(CurrencyConverter))] - public Currency FeeAmount { get; set; } + public Currency FeeAmountDelta { get; set; } /// [JsonPropertyName("MaxFee")] @@ -161,8 +166,8 @@ public class SponsorshipSetResponse : TransactionResponse, ISponsorshipSet public Currency MaxFee { get; set; } /// - [JsonPropertyName("RemainingOwnerCount")] - public uint? RemainingOwnerCount { get; set; } + [JsonPropertyName("RemainingOwnerCountDelta")] + public int? RemainingOwnerCountDelta { get; set; } } public partial class Validation @@ -179,10 +184,40 @@ public static async Task ValidateSponsorshipSet(Dictionary tx) if (hasSponsee == hasCounterpartySponsor) throw new ValidationException("SponsorshipSet: exactly one of Sponsee or CounterpartySponsor must be present"); - if (tx.TryGetValue("RemainingOwnerCount", out var roc) && !Common.IsUInt32(roc)) - throw new ValidationException("SponsorshipSet: invalid RemainingOwnerCount"); - uint flags = ExtractFlags(tx); + bool isDelete = (flags & (uint)SponsorshipSetFlags.tfDeleteObject) != 0; + + bool hasFeeAmountDelta = tx.TryGetValue("FeeAmountDelta", out var feeDelta) && feeDelta is not null; + bool hasRemainingOwnerCountDelta = tx.TryGetValue("RemainingOwnerCountDelta", out var rocDelta) && rocDelta is not null; + bool hasMaxFee = tx.TryGetValue("MaxFee", out var maxFee) && maxFee is not null; + + // rippled SponsorshipSet::preflight: a delete carries no modification fields + if (isDelete && (hasFeeAmountDelta || hasRemainingOwnerCountDelta || hasMaxFee)) + throw new ValidationException("SponsorshipSet: tfDeleteObject cannot be combined with FeeAmountDelta, RemainingOwnerCountDelta or MaxFee"); + + if (hasRemainingOwnerCountDelta) + { + // The field is serialized as Int32 and may be negative, but never zero (temINVALID) + if (!Common.TryGetInt32(rocDelta, out int delta)) + throw new ValidationException("SponsorshipSet: RemainingOwnerCountDelta must be a number"); + + if (delta == 0) + throw new ValidationException("SponsorshipSet: RemainingOwnerCountDelta must not be zero"); + } + + if (hasFeeAmountDelta) + { + // rippled SponsorshipSet::preflight: a non-zero XRP amount, so drops + // as a string — an issued currency object is temBAD_AMOUNT. The delta + // may be negative, which reclaims budget from the Sponsorship object. + if (feeDelta is not string drops || + !long.TryParse(drops, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out long dropsValue)) + throw new ValidationException("SponsorshipSet: FeeAmountDelta must be an XRP amount in drops"); + + if (dropsValue == 0) + throw new ValidationException("SponsorshipSet: FeeAmountDelta must not be zero"); + } + const uint feePair = (uint)(SponsorshipSetFlags.tfSponsorshipSetRequireSignForFee | SponsorshipSetFlags.tfSponsorshipClearRequireSignForFee); const uint reservePair = (uint)(SponsorshipSetFlags.tfSponsorshipSetRequireSignForReserve | SponsorshipSetFlags.tfSponsorshipClearRequireSignForReserve); if ((flags & feePair) == feePair || (flags & reservePair) == reservePair) diff --git a/Xrpl/Models/Transactions/TxFormat.cs b/Xrpl/Models/Transactions/TxFormat.cs index 5d43aa4a..0e1cd8b1 100644 --- a/Xrpl/Models/Transactions/TxFormat.cs +++ b/Xrpl/Models/Transactions/TxFormat.cs @@ -623,7 +623,7 @@ static TxFormat() [Field.MaximumAmount] = Requirement.Optional, [Field.MPTokenMetadata] = Requirement.Optional, [Field.DomainID] = Requirement.Optional, - [Field.MutableFlags] = Requirement.Optional, + [Field.ImmutableFlags] = Requirement.Optional, }, [BinaryCodec.Types.TransactionType.MPTokenIssuanceDestroy] = new TxFormat { @@ -636,7 +636,7 @@ static TxFormat() [Field.DomainID] = Requirement.Optional, [Field.MPTokenMetadata] = Requirement.Optional, [Field.TransferFee] = Requirement.Optional, - [Field.MutableFlags] = Requirement.Optional, + [Field.ImmutableFlags] = Requirement.Optional, [Field.IssuerEncryptionKey] = Requirement.Optional, [Field.AuditorEncryptionKey] = Requirement.Optional, }, @@ -651,9 +651,9 @@ static TxFormat() { [Field.CounterpartySponsor] = Requirement.Optional, [Field.Sponsee] = Requirement.Optional, - [Field.FeeAmount] = Requirement.Optional, + [Field.FeeAmountDelta] = Requirement.Optional, [Field.MaxFee] = Requirement.Optional, - [Field.RemainingOwnerCount] = Requirement.Optional, + [Field.RemainingOwnerCountDelta] = Requirement.Optional, }, [BinaryCodec.Types.TransactionType.SponsorshipTransfer] = new TxFormat { From a578a232d3525f66fa609f67340933324a56ea33 Mon Sep 17 00:00:00 2001 From: Aleksandr <44946855+Platonenkov@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:07:36 +0000 Subject: [PATCH 11/14] =?UTF-8?q?feat(codec)!:=20MPT-=D1=85=D0=BE=D0=BF?= =?UTF-8?q?=D1=8B=20(0x40)=20=D0=B2=20PathSet,=20PathStepType=20=D0=B2?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20int=20=D0=B2=20=D1=88=D0=B0?= =?UTF-8?q?=D0=B3=D0=B5=20=D0=BF=D1=83=D1=82=D0=B8=20(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(codec): MPT-хопы (0x40) в PathSet, mpt_issuance_id в шаге пути PathSet знал только три классических бита типа хопа — 0x01 account, 0x10 currency, 0x20 issuer. rippled добавил STPathElement::TypeMpt = 0x40 в 3.2.0: хоп может нести 24-байтный MPTokenIssuanceID вместо валюты. Пробел был тихим в обе стороны. FromParser не совпадал ни с одной маской на байте 0x40, создавал пустой хоп и не считывал 24 байта MPTID — дальше весь блоб разбирался со сдвигом, без единого исключения. SynthesizeType не умел выставить бит вовсе, то есть собрать MPT-путь было нечем. Что сделано в кодеке (Base/Xrpl.BinaryCodec/Types/PathSet.cs): * PathHop.MptIssuanceId (Hash192), второй конструктор, HasMpt(), константы TypeMpt и TypeAll (0x71). currency и mpt_issuance_id в одном шаге — InvalidJsonException, как rippled бросает "bad path element: MPT and Currency" * порядок записи повторяет STPathSet::add(): байт типа, account(20), MPTID(24), currency(20), issuer(20) * FromParser отвергает то же, что и rippled: байт типа с битами вне TypeAll, currency вместе с MPT и пустой путь (ведущий или сдвоенный разделитель 0xFF). Раньше любой мусорный байт принимался, а пустой путь переживал декодирование и исчезал при обратной сборке — блоб и хеш транзакции не совпадали с исходными * не-строковый mpt_issuance_id даёт InvalidJsonException, а не сырой InvalidOperationException из JsonNode — так же, как это делают Amount и Issue Модель (Xrpl/Models/Methods/Path.cs): * добавлено MPTokenIssuanceID (mpt_issuance_id) — шаг с MPT из ripple_path_find/path_find было нечем представить * TypeHex помечен [Obsolete]: rippled убрал type_hex из STPath::getJson в 1.7.0 (коммит f0724694), в jss.h осталось только неиспользуемое объявление. Проверено на mainnet: 19 транзакций с Paths в трёх подряд идущих леджерах, 21 шаг пути, type присутствует во всех 21, type_hex — ни в одном; то же в ripple_path_find на s1/s2.ripple.com * у Type уточнена документация: документация XRPL помечает поле устаревшим, но rippled по-прежнему отдаёт его в каждом шаге каждого ответа, поэтому поле остаётся. На исходящем пути оно игнорируется — STParsedJSON читает только account/currency/mpt_issuance_id/issuer, а кодек синтезирует байт из фактически заданных полей * Payment.IsPathStep принимает mpt_issuance_id как актив шага и отвергает его в паре с currency Тесты (Tests/Xrpl.BinaryCodec.Test/Types/TestUPathSet.cs, 9 штук): раскладка классического хопа (0x30), MPT-хопа (0x40) и MPT+issuer (0x60) против rippled, round-trip декодирования, три пути отказа и тест, пиннящий синтез байта типа: удаление type или заведомо неверный type/type_hex не меняют блоб. Опережает сеть осознанно: MPTokensV2 на mainnet не включена и сейчас не голосуется, xrpl.js и xrpl-py бит 0x40 тоже не знают. Проверено: юнит-тесты 1036/1036. Отдельно — регрессия на реальной транзакции 1D813B78FC55ABF9054AEBD2AF9DD7C90361F9985B7897E8E9A592D63BF0CC43: блоб, собранный кодеком из её tx_json, совпадает с mainnet-блобом побайтово. * refactor(models)!: тип шага пути — [Flags] enum PathStepType вместо int Байт типа хопа — битовая маска, но оба слоя писали её голым числом: Path.Type был int?, PathHop.Type — int, и в коде вызывающего это превращалось в сравнения с магическим 48. Ledger-объекты давно живут иначе: AccountRootFlags и ещё восемь [Flags]-энумов стоят прямо в свойстве. Приводим шаг пути к тому же виду. PathStepType (Base/Xrpl.BinaryCodec/Enums/PathStepType.cs) — один enum на оба слоя: Account 0x01, Currency 0x10, Issuer 0x20, MPTokenIssuanceID 0x40, All 0x71. Живёт в кодеке, потому что участвует в бинарной сериализации; модель ссылается на него так же, как TxFormat и MPTokenIssuanceCreate уже ссылаются на типы BinaryCodec. Ломающее: Path.Type теперь PathStepType?, PathHop.Type — PathStepType, а константы PathHop.TypeAccount/TypeCurrency/TypeIssuer/TypeMpt/TypeAll удалены в пользу enum. Код вида step.Type == 48 перестанет собираться, чинится как PathStepType.Currency | PathStepType.Issuer. Формат на проводе не меняется: XrplJsonOptions намеренно не регистрирует глобальный JsonStringEnumConverter — протокольные enum числовые, — поэтому type по-прежнему пишется и читается как число. Значение с битом, которого enum не объявляет, переживает десериализацию без потерь (проверено на 176). Единственная потеря терпимости: "type":"48" строкой больше не разбирается, так как NumberHandling.AllowReadingFromString на enum не распространяется; rippled всегда шлёт число. TestUPathStep (5 тестов) пиннит разбор 48 и 96 во флаги, числовую форму на проводе, сохранение необъявленного бита и null при отсутствии поля. Проверено: юнит-тесты 1060/1060, блоб реальной mainnet-транзакции 1D813B78FC55ABF9054AEBD2AF9DD7C90361F9985B7897E8E9A592D63BF0CC43 по-прежнему собирается побайтово идентично. * fix(models): замечания CodeRabbit к PR #82 — шаги пути по правилам toStrand Три находки, все приняты. 1. Payment.IsPathStep пропускал account вместе с активом (Major). Метод был портом xrpl.js isPathStep, который принимает {account, currency} и {account, issuer}; после добавления MPT туда же попало {account, mpt_issuance_id}. rippled такие пути отвергает до исполнения — toStrand() в PaySteps.cpp: if (hasAccount && (hasIssuer || hasCurrency)) return {temBAD_PATH, Strand{}}; if (hasMPT && (hasCurrency || hasAccount)) return {temBAD_PATH, Strand{}}; То есть SDK формировал транзакцию с гарантированным temBAD_PATH. Валидация приведена к правилам toStrand; расхождение с xrpl.js осознанное — там пробел, а не контракт. Проверено эмпирически: в 40 подряд идущих mainnet-леджерах 166 шагов пути, типы только 48 (currency+issuer), 1 (account) и 16 (currency) — ни одной комбинации account с активом, так что ужесточение не отвергает то, что сеть реально использует. 2. Пустые пути отвергались только на одной границе кодека (Major). Прошлый коммит закрыл ведущий и сдвоенный разделитель при разборе, но мимо прошли ещё два случая: ToBytes молча выбрасывал пустой Path (на выходе получался блоб без этого пути), а разбор принимал терминатор сразу после разделителя — хвостовой пустой путь. Оба места теперь бросают BinaryCodecException, как rippled в STPathSet(SerialIter&). 3. CHANGES.md ссылался на удалённые константы (Minor). TypeMpt/TypeAll исчезли вместе с переходом на PathStepType — текст исправлен на PathStepType.MPTokenIssuanceID и PathStepType.All. Тесты: TestUPathSetEmptyPathThrowsOnEncode, TestUPathSetTrailingSeparatorThrowsOnDecode, TestUPathStepValidationMatchesRippledToStrand. Проверено: юнит-тесты 1063/1063, блоб mainnet-транзакции 1D813B78FC55ABF9054AEBD2AF9DD7C90361F9985B7897E8E9A592D63BF0CC43 по-прежнему собирается побайтово идентично. * refactor(models): PathStepType переезжает в модели, кодек остаётся байтовым Кодек — низкоуровневый слой, ему незачем экспортировать перечисление, которое становится частью публичной поверхности моделей, а моделям незачем тянуть namespace кодека ради имени типа. В репозитории эта граница уже проведена: Xrpl.Models.Enums.TransactionType и LedgerEntryType живут отдельно от BinaryCodec.Types.TransactionType. PathStepType переехал в Xrpl/Models/Enums/PathStepType.cs и используется только в Path.Type. В кодеке возвращены байтовые константы TypeAccount/TypeCurrency/TypeIssuer/TypeMpt/TypeAll, PathHop.Type снова byte, маски в FromParser считаются по ним. Base/Xrpl.BinaryCodec/Enums/ PathStepType.cs удалён; Xrpl/Models/Methods/Path.cs больше не ссылается на BinaryCodec. Отдельно: ToJson пишет байт типа как int, а не как byte. JsonValue отказывается отдавать GetValue(), то есть потребитель Decode, читавший type как int, получил бы InvalidOperationException — это поймал TestUPathSetMptHopRoundTrips после перевода поля в byte. Проверено: юнит-тесты 1063/1063, блоб mainnet-транзакции 1D813B78FC55ABF9054AEBD2AF9DD7C90361F9985B7897E8E9A592D63BF0CC43 собирается побайтово идентично, decode возвращает type: 48. * refactor(models)!: удалить Path.TypeHex вместо пометки [Obsolete] Помечать устаревшим имеет смысл то, что работает и однажды перестанет. type_hex не работает с февраля 2021: rippled убрал его из STPath::getJson в 1.7.0 (коммит f0724694), в jss.h осталось только неиспользуемое объявление. Свойство не могло принять ничего, кроме null, ни на одной существующей ноде — это не устаревающий контракт, а мёртвая поверхность. Удалено сразу, без периода [Obsolete], как в 10.11.0.0 удалялись свойства ledger-объектов, не являющиеся полями протокола. Ответ ноды старее 1.7.0 по-прежнему разбирается: неизвестный ключ System.Text.Json игнорирует, UnmappedMemberHandling.Disallow в XrplJsonOptions не выставлен. Закреплено тестом TestUPathStepIgnoresLegacyTypeHex. Тест кодека, доказывающий что лишний type_hex во входном JSON не влияет на блоб, оставлен как есть — он о поведении кодека, а не о модели. Проверено: юнит-тесты 1064/1064. --- Base/Xrpl.BinaryCodec/Types/PathSet.cs | 108 +++++++++-- CHANGES.md | 13 ++ .../Types/TestUPathSet.cs | 170 ++++++++++++++++++ Tests/Xrpl.Tests/Models/TestUPathStep.cs | 128 +++++++++++++ Xrpl/Models/Enums/PathStepType.cs | 27 +++ Xrpl/Models/Methods/Path.cs | 25 ++- Xrpl/Models/Transactions/Payment.cs | 12 +- 7 files changed, 457 insertions(+), 26 deletions(-) create mode 100644 Tests/Xrpl.BinaryCodec.Test/Types/TestUPathSet.cs create mode 100644 Tests/Xrpl.Tests/Models/TestUPathStep.cs create mode 100644 Xrpl/Models/Enums/PathStepType.cs diff --git a/Base/Xrpl.BinaryCodec/Types/PathSet.cs b/Base/Xrpl.BinaryCodec/Types/PathSet.cs index f4686e66..3108f78b 100644 --- a/Base/Xrpl.BinaryCodec/Types/PathSet.cs +++ b/Base/Xrpl.BinaryCodec/Types/PathSet.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Text.Json.Nodes; using Xrpl.BinaryCodec.Binary; @@ -8,7 +9,7 @@ namespace Xrpl.BinaryCodec.Types { - /// The object representation of a Hop, an issuer AccountID, an account AccountID, and a Currency + /// The object representation of a Hop, an issuer AccountID, an account AccountID, and a Currency or an MPTokenIssuanceID public class PathHop { #region Constant for masking types of a Hop @@ -19,6 +20,10 @@ public class PathHop public const byte TypeCurrency = 0x10; /// type issuer const byte public const byte TypeIssuer = 0x20; + /// MPTokenIssuanceID const byte (rippled 3.2.0+, MPTokensV2 amendment) + public const byte TypeMpt = 0x40; + /// Every bit a hop type byte is allowed to carry + public const byte TypeAll = TypeAccount | TypeCurrency | TypeIssuer | TypeMpt; #endregion /// account AccountID @@ -27,17 +32,34 @@ public class PathHop public readonly AccountId Issuer; /// Currency public readonly Currency Currency; - /// Hop type - public readonly int Type; + /// MPTokenIssuanceID, mutually exclusive with + public readonly Hash192 MptIssuanceId; + /// Hop type byte, synthesized from the fields present + public readonly byte Type; /// Create a Hop /// account AccountID /// issuer AccountID /// Currency public PathHop(AccountId account, AccountId issuer, Currency currency) + : this(account, issuer, currency, null) { + } + /// Create a Hop + /// account AccountID + /// issuer AccountID + /// Currency, mutually exclusive with mptIssuanceId + /// MPTokenIssuanceID, mutually exclusive with currency + public PathHop(AccountId account, AccountId issuer, Currency currency, Hash192 mptIssuanceId) + { + if (currency != null && mptIssuanceId != null) + { + throw new InvalidJsonException("Path step cannot hold both currency and mpt_issuance_id."); + } + Account = account; Issuer = issuer; Currency = currency; + MptIssuanceId = mptIssuanceId; Type = SynthesizeType(); } /// Deserialize Hot @@ -45,7 +67,18 @@ public PathHop(AccountId account, AccountId issuer, Currency currency) /// public static PathHop FromJson(JsonNode json) { - return new PathHop(json["account"], json["issuer"], json["currency"]); + JsonNode mptIssuanceId = json["mpt_issuance_id"]; + if (mptIssuanceId != null + && (!(mptIssuanceId is JsonValue mptJv) || mptJv.GetValueKind() != JsonValueKind.String)) + { + throw new InvalidJsonException("Path step property `mpt_issuance_id` must be a JSON string."); + } + + return new PathHop( + json["account"], + json["issuer"], + json["currency"], + mptIssuanceId == null ? null : Hash192.FromJson(mptIssuanceId)); } /// check that hop has issuer AccountID public bool HasIssuer() => Issuer != null; @@ -53,11 +86,13 @@ public static PathHop FromJson(JsonNode json) public bool HasCurrency() => Currency != null; /// check that hop has account AccountID public bool HasAccount() => Account != null; + /// check that hop has MPTokenIssuanceID + public bool HasMpt() => MptIssuanceId != null; /// /// generate type for current hop /// /// - public int SynthesizeType() + public byte SynthesizeType() { var type = 0; @@ -73,13 +108,18 @@ public int SynthesizeType() { type |= TypeIssuer; } - return type; + if (HasMpt()) + { + type |= TypeMpt; + } + return (byte)type; } /// Serialize Hop /// public JsonObject ToJson() { - JsonObject hop = new JsonObject { ["type"] = Type }; + // int, not byte: a JsonValue would refuse GetValue() for consumers of Decode + JsonObject hop = new JsonObject { ["type"] = (int)Type }; if (HasAccount()) { @@ -89,6 +129,10 @@ public JsonObject ToJson() { hop["currency"] = JsonValue.Create(Currency.ToString()); } + if (HasMpt()) + { + hop["mpt_issuance_id"] = JsonValue.Create(MptIssuanceId.ToString()); + } if (HasIssuer()) { hop["issuer"] = JsonValue.Create(Issuer.ToString()); @@ -160,17 +204,26 @@ public void ToBytes(IBytesSink buffer) var n = 0; foreach (var path in this) { + if (path.Count == 0) + { + throw new BinaryCodecException("Empty path in pathset"); + } if (n++ != 0) { buffer.Put(PathSeparatorByte); } foreach (var hop in path) { - buffer.Put((byte)hop.Type); + // Field order mirrors rippled STPathSet::add(): account, MPT, currency, issuer + buffer.Put(hop.Type); if (hop.HasAccount()) { buffer.Put(hop.Account.Buffer); } + if (hop.HasMpt()) + { + buffer.Put(hop.MptIssuanceId.Buffer); + } if (hop.HasCurrency()) { buffer.Put(hop.Currency.Buffer); @@ -207,6 +260,7 @@ public static PathSet FromJson(JsonNode token) /// Construct a PathSet from a BinaryParser /// /// A BinaryParser to read PathSet from + /// unused, kept for the ISerializedType parser signature /// public static PathSet FromParser(BinaryParser parser, int? hint=null) { @@ -214,9 +268,14 @@ public static PathSet FromParser(BinaryParser parser, int? hint=null) Path path = null; while (!parser.End()) { - byte type = parser.ReadOne(); - if (type == PathsetEndByte) + byte rawType = parser.ReadOne(); + if (rawType == PathsetEndByte) { + // a terminator right after a separator means a trailing empty path + if (path == null && pathSet.Count > 0) + { + throw new BinaryCodecException("Empty path in pathset"); + } break; } if (path == null) @@ -224,29 +283,46 @@ public static PathSet FromParser(BinaryParser parser, int? hint=null) path = new Path(); pathSet.Add(path); } - if (type == PathSeparatorByte) + if (rawType == PathSeparatorByte) { + if (path.Count == 0) + { + throw new BinaryCodecException("Empty path in pathset"); + } path = null; continue; } + if ((rawType & ~PathHop.TypeAll) != 0) + { + throw new BinaryCodecException("Bad path element in pathset: unknown type bits"); + } + if ((rawType & PathHop.TypeCurrency) != 0 && (rawType & PathHop.TypeMpt) != 0) + { + throw new BinaryCodecException("Bad path element in pathset: both currency and MPT"); + } AccountId account = null; AccountId issuer = null; Currency currency = null; + Hash192 mptIssuanceId = null; - if ((type & PathHop.TypeAccount) != 0) + if ((rawType & PathHop.TypeAccount) != 0) { account = AccountId.FromParser(parser); } - if ((type & PathHop.TypeCurrency) != 0) + if ((rawType & PathHop.TypeMpt) != 0) + { + mptIssuanceId = Hash192.FromParser(parser); + } + if ((rawType & PathHop.TypeCurrency) != 0) { currency = Currency.FromParser(parser); } - if ((type & PathHop.TypeIssuer) != 0) + if ((rawType & PathHop.TypeIssuer) != 0) { issuer = AccountId.FromParser(parser); } - var hop = new PathHop(account, issuer, currency); + var hop = new PathHop(account, issuer, currency, mptIssuanceId); path.Add(hop); } @@ -254,4 +330,4 @@ public static PathSet FromParser(BinaryParser parser, int? hint=null) } } -} \ No newline at end of file +} diff --git a/CHANGES.md b/CHANGES.md index 57b2c455..0ae70c4a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,19 @@ ## 10.11.0.0 08/04/2026 +* **MPT path steps (`0x40`)** — `PathSet` only knew the three classic hop-type bits (`0x01` account, `0x10` currency, `0x20` issuer). rippled added `STPathElement::TypeMpt = 0x40` in **3.2.0**, so a hop can now carry a 24-byte `MPTokenIssuanceID` instead of a currency. The gap was silent in both directions: `FromParser` matched none of its masks on a `0x40` byte, produced an empty hop and left the 24 MPTID bytes unread — every following byte was then parsed at the wrong offset — while `SynthesizeType` had no way to emit the bit at all. Now handled end to end: + * `PathHop.MptIssuanceId` (`Hash192`) with a second constructor, `HasMpt()` and the `TypeMpt`/`TypeAll` byte constants; `currency` and `mpt_issuance_id` in one step throw `InvalidJsonException`, matching rippled, which throws `bad path element: MPT and Currency` + * serialization order mirrors `STPathSet::add()` — type byte, then account(20), MPTID(24), currency(20), issuer(20) + * `FromParser` now rejects what rippled rejects: a type byte carrying bits outside `TypeAll` (`0x71`), currency together with MPT, and an empty path — a leading or doubled `0xFF` separator, or a terminator that follows one. Previously any garbage byte was accepted and silently mis-parsed, and an empty path survived decoding but vanished on re-encoding, so the blob and the transaction hash no longer matched the bytes that were read + * `ToBytes` throws on an empty `Path` instead of writing it away silently — the encoding side of the same asymmetry + * a non-string `mpt_issuance_id` raises `InvalidJsonException` instead of a raw `InvalidOperationException` from the JSON node, matching how `Amount` and `Issue` report the same mistake + * `Payment.IsPathStep` accepts `mpt_issuance_id` as a valid step asset and now follows rippled's `toStrand()` rules instead of the looser `xrpl.js` port it was: `account` combined with `currency`, `issuer` or `mpt_issuance_id`, and `currency` combined with `mpt_issuance_id`, are all `temBAD_PATH` upstream and are rejected before the transaction is sent. `xrpl.js` `isPathStep` still accepts `account` + asset — that is a gap on their side, not a compatibility requirement + * `TestUPathSet` pins the layout of both the classic and the MPT hop against rippled's, plus the round trip and every rejection path; `TestUPathStep` pins the step-validation rules against `toStrand()` + * Note this is ahead of the network: `MPTokensV2` is not enabled on mainnet (and not currently in `Majorities`), so MPT hops cannot yet appear in a validated ledger. `xrpl.js` and `xrpl-py` do not handle `0x40` either +* **`Path.MPTokenIssuanceID`** — the `mpt_issuance_id` key of a path step was missing from the model, so a step read from `ripple_path_find`/`path_find` could not be represented, let alone sent back +* **`Path.TypeHex` removed** (**breaking**, no `[Obsolete]` grace period, consistent with the 10.11.0.0 removal of ledger-object properties that are not protocol fields) — rippled removed `type_hex` from `STPath::getJson` in **1.7.0** (commit `f0724694`); only the unused `JSS(type_hex)` declaration survives in `jss.h`. No server has emitted the field for five years, so the property could never be anything but `null` — there is nothing to deprecate, only dead surface to delete. Verified against mainnet: 19 transactions carrying `Paths` across three consecutive ledgers, 21 path steps, `type` present in all 21 and `type_hex` in none, plus `ripple_path_find` on `s1`/`s2.ripple.com`. A response from a pre-1.7.0 server still deserializes — the unmapped key is ignored, which `TestUPathStepIgnoresLegacyTypeHex` pins +* **`Path.Type` is a `[Flags]` enum now** (**breaking**) — the hop type is a bitmask, but the model spelled it as a bare `int?`, so callers compared against magic `48`. It is now `PathStepType` (`Xrpl.Models.Enums`), matching how ledger objects already type their flags (`AccountRootFlags` and eight more) and how `TransactionType`/`LedgerEntryType` already exist model-side next to their codec counterparts. The enum is deliberately **not** shared with `Xrpl.BinaryCodec`: the codec stays byte-level — `PathHop.Type` is a `byte` synthesized from the `TypeAccount`/`TypeCurrency`/`TypeIssuer`/`TypeMpt` constants — so the model does not drag a codec namespace into its public surface. The wire format is unchanged: `XrplJsonOptions` deliberately registers no global `JsonStringEnumConverter` because XRPL protocol enums are numeric, and a value carrying a bit the enum does not declare survives deserialization untouched, which `TestUPathStep` pins along with the numeric wire form. The one behavioural loss: `"type":"48"` sent as a *string* no longer parses, since `NumberHandling.AllowReadingFromString` does not apply to enums; rippled always sends it as a number +* **`Path.Type` documented as read-only** — the XRPL docs mark it deprecated, but every rippled version still emits it on every step, so it stays. What the doc comment now states is that it is ignored on the way out: rippled's `STParsedJSON` reads only `account`/`currency`/`mpt_issuance_id`/`issuer` from a submitted step, and the binary codec derives the byte from the fields actually present. Pinned by `TestUPathSetHopTypeIsSynthesizedNotReadFromJson` — dropping `type`, or setting a deliberately wrong one, must not change the blob * **rippled 3.3.0 — the CI stand and two protocol surfaces the SDK had modelled from a stale `develop` snapshot** (**breaking**). The stand moves from 3.2.1 to the 3.3.0 release image, which activates `BatchV1_1`, `Sponsor`, `PermissionDelegationV1_1`, `DynamicMPT`, `ConfidentialTransfer` and `fixCleanup3_3_0` at genesis, so 44 previously `AmendmentGuard`-skipped integration tests run for real on every CI run. 17 of them failed there: both features had been implemented against the nightly build the stand is pinned to (`3.3.0~b1+202607110018`, 11 Jul 2026) and upstream changed their shape before the release. Neither change is visible in `definitions.json` field codes alone, which is why nothing caught it earlier — see the Definitions Watch note below: * **DynamicMPT: `MutableFlags` is now `ImmutableFlags`, with the meaning inverted.** Same `UInt32` field, same nth 53, same bit values — but a set bit no longer means "this may be changed later", it means "this is frozen forever" (`rippled` `MPTokenIssuanceSet::preclaim`: `isImmutable(flag) => currentImmutableFlags & flag`). An issuance created without the field is therefore fully mutable, where before it was fully immutable — the exact opposite default. Because the field code did not change, the old models produced a blob the node accepted and then read backwards: mutations came back `tecNO_PERMISSION`, freezes silently succeeded, and `ledger_entry` returned an `ImmutableFlags` key the model did not bind. `MPTokenIssuanceCreateMutableFlags` and `MPTokenIssuanceSetMutableFlags` are replaced by a single `MPTokenIssuanceImmutableFlags` (`tif*`, aliasing the `lsif*` ledger constants) shared by both transactions and `LOMPTokenIssuance.ImmutableFlags` * **DynamicMPT: enabling a capability moved from a field to transaction flags.** The old `MPTokenIssuanceSet.MutableFlags = tmfMPTSet*` no longer exists; a capability is now enabled through `Flags = tfMPTSetCanLock | tfMPTSetRequireAuth | tfMPTSetCanEscrow | tfMPTSetCanTrade | tfMPTSetCanTransfer | tfMPTSetCanClawback | tfMPTSetCanHoldConfidentialBalance` (0x04–0x100), added to `MPTokenIssuanceSetFlags` next to the existing `tfMPTLock`/`tfMPTUnlock`. `ImmutableFlags` on the same transaction now does the opposite job — it freezes capabilities and fields, OR-ed into the ledger object, never cleared diff --git a/Tests/Xrpl.BinaryCodec.Test/Types/TestUPathSet.cs b/Tests/Xrpl.BinaryCodec.Test/Types/TestUPathSet.cs new file mode 100644 index 00000000..a8bf99bb --- /dev/null +++ b/Tests/Xrpl.BinaryCodec.Test/Types/TestUPathSet.cs @@ -0,0 +1,170 @@ +using System.Text.Json.Nodes; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Xrpl.BinaryCodec; + +namespace XrplTests.BinaryCodecLib.Types; + +/// +/// PathSet serialization tests. +/// Layout mirrors rippled STPathSet::add(): type byte, then account(20), MPTokenIssuanceID(24), +/// currency(20) and issuer(20) for whichever bits the type byte carries. +/// Type bits: 0x01 account, 0x10 currency, 0x20 issuer, 0x40 MPT (rippled 3.2.0+, MPTokensV2). +/// +[TestClass] +public class TestUPathSet +{ + private const string PathsFieldHeader = "0112"; + private const string PathSetEnd = "00"; + + // rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3 + private const string IssuerAccountHex = "7720BA5CE66725906C2D74C7E8ADB1557556691A"; + private const string CurrencyHex = "4249547800000000000000000000000000000000"; + private const string MptIssuanceId = "00000001A407AF5856CCA3379B1EC94E1D2C5B99C1BE89C2"; + + private static string PaymentWithPaths(string pathStepsJson) => @"{ + ""Account"": ""rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"", + ""TransactionType"": ""Payment"", + ""Destination"": ""rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"", + ""Amount"": { ""currency"": ""4249547800000000000000000000000000000000"", ""issuer"": ""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"", ""value"": ""1"" }, + ""SendMax"": ""100000000"", + ""Fee"": ""12"", + ""Sequence"": 1, + ""Paths"": [[" + pathStepsJson + @"]] + }"; + + private static string Encode(string pathStepsJson) => + XrplBinaryCodec.Encode(JsonNode.Parse(PaymentWithPaths(pathStepsJson))); + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathSetCurrencyIssuerHopMatchesRippledLayout() + { + // Same shape as mainnet tx 1D813B78FC55ABF9054AEBD2AF9DD7C90361F9985B7897E8E9A592D63BF0CC43 + string encoded = Encode(@"{ ""currency"": ""4249547800000000000000000000000000000000"", ""issuer"": ""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"", ""type"": 48 }"); + + string expected = PathsFieldHeader + "30" + CurrencyHex + IssuerAccountHex + PathSetEnd; + StringAssert.Contains(encoded, expected, $"PathSet bytes should match rippled layout. Got: {encoded}"); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathSetHopTypeIsSynthesizedNotReadFromJson() + { + // rippled derives the type byte from the fields present and ignores the JSON "type"/"type_hex" + // keys on input, so neither a missing nor a wrong value may change the produced blob. + string withCorrectType = Encode(@"{ ""currency"": ""4249547800000000000000000000000000000000"", ""issuer"": ""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"", ""type"": 48 }"); + string withoutType = Encode(@"{ ""currency"": ""4249547800000000000000000000000000000000"", ""issuer"": ""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"" }"); + string withWrongType = Encode(@"{ ""currency"": ""4249547800000000000000000000000000000000"", ""issuer"": ""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"", ""type"": 1, ""type_hex"": ""0000000000000001"" }"); + + Assert.AreEqual(withCorrectType, withoutType, "Removing the type key must not change the blob"); + Assert.AreEqual(withCorrectType, withWrongType, "A wrong type/type_hex must not change the blob"); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathSetMptHopMatchesRippledLayout() + { + string encoded = Encode(@"{ ""mpt_issuance_id"": ""00000001A407AF5856CCA3379B1EC94E1D2C5B99C1BE89C2"" }"); + + string expected = PathsFieldHeader + "40" + MptIssuanceId + PathSetEnd; + StringAssert.Contains(encoded, expected, $"MPT hop should serialize as 0x40 + 24-byte MPTokenIssuanceID. Got: {encoded}"); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathSetMptWithIssuerHopMatchesRippledLayout() + { + string encoded = Encode(@"{ ""mpt_issuance_id"": ""00000001A407AF5856CCA3379B1EC94E1D2C5B99C1BE89C2"", ""issuer"": ""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"" }"); + + // rippled STPathSet::add() writes MPT before issuer + string expected = PathsFieldHeader + "60" + MptIssuanceId + IssuerAccountHex + PathSetEnd; + StringAssert.Contains(encoded, expected, $"MPT+issuer hop should serialize as 0x60 + MPTID + issuer. Got: {encoded}"); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathSetMptHopRoundTrips() + { + string encoded = Encode(@"{ ""mpt_issuance_id"": ""00000001A407AF5856CCA3379B1EC94E1D2C5B99C1BE89C2"", ""issuer"": ""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"" }"); + + JsonNode decoded = XrplBinaryCodec.Decode(encoded); + JsonNode hop = decoded["Paths"][0][0]; + + Assert.AreEqual(MptIssuanceId, hop["mpt_issuance_id"]?.ToString(), "MPTokenIssuanceID should round-trip"); + Assert.AreEqual("rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3", hop["issuer"]?.ToString(), "Issuer should round-trip"); + Assert.AreEqual(0x60, hop["type"]?.GetValue(), "Decoded hop should report the synthesized type byte"); + Assert.IsNull(hop["currency"], "MPT hop must not carry a currency"); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathSetCurrencyAndMptTogetherThrows() + { + Assert.ThrowsExactly( + () => Encode(@"{ ""currency"": ""4249547800000000000000000000000000000000"", ""mpt_issuance_id"": ""00000001A407AF5856CCA3379B1EC94E1D2C5B99C1BE89C2"" }"), + "A path step holding both currency and mpt_issuance_id must be rejected, as rippled does"); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathSetNonStringMptIssuanceIdThrows() + { + Assert.ThrowsExactly( + () => Encode(@"{ ""mpt_issuance_id"": 42 }"), + "A non-string mpt_issuance_id must be reported as invalid JSON, like Amount and Issue do"); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathSetEmptyPathThrowsOnDecode() + { + string encoded = Encode(@"{ ""currency"": ""4249547800000000000000000000000000000000"", ""issuer"": ""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"" }"); + + // insert a leading path separator, which makes the first path empty + string corrupted = encoded.Replace(PathsFieldHeader + "30", PathsFieldHeader + "FF30"); + Assert.AreNotEqual(encoded, corrupted, "Test setup should have inserted the separator"); + + Assert.ThrowsExactly( + () => XrplBinaryCodec.Decode(corrupted), + "An empty path must be rejected, as rippled does"); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathSetEmptyPathThrowsOnEncode() + { + Assert.ThrowsExactly( + () => XrplBinaryCodec.Encode(JsonNode.Parse(PaymentWithPaths(string.Empty))), + "An empty path must not be encoded away silently"); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathSetTrailingSeparatorThrowsOnDecode() + { + string encoded = Encode(@"{ ""currency"": ""4249547800000000000000000000000000000000"", ""issuer"": ""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"" }"); + + // append a separator right before the terminator, i.e. a trailing empty path + string corrupted = encoded.Replace(IssuerAccountHex + "00", IssuerAccountHex + "FF00"); + Assert.AreNotEqual(encoded, corrupted, "Test setup should have inserted the trailing separator"); + + Assert.ThrowsExactly( + () => XrplBinaryCodec.Decode(corrupted), + "A terminator following a separator must be rejected, as rippled does"); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathSetUnknownTypeBitsThrowOnDecode() + { + string encoded = Encode(@"{ ""currency"": ""4249547800000000000000000000000000000000"", ""issuer"": ""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"" }"); + + // flip the hop type byte to a value carrying a bit outside TypeAll (0x71) + string corrupted = encoded.Replace(PathsFieldHeader + "30" + CurrencyHex, PathsFieldHeader + "02" + CurrencyHex); + Assert.AreNotEqual(encoded, corrupted, "Test setup should have patched the hop type byte"); + + Assert.ThrowsExactly( + () => XrplBinaryCodec.Decode(corrupted), + "A hop type byte with unknown bits must be rejected, as rippled does"); + } +} diff --git a/Tests/Xrpl.Tests/Models/TestUPathStep.cs b/Tests/Xrpl.Tests/Models/TestUPathStep.cs new file mode 100644 index 00000000..8e565c06 --- /dev/null +++ b/Tests/Xrpl.Tests/Models/TestUPathStep.cs @@ -0,0 +1,128 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System.Collections.Generic; +using System.Text.Json; + +using Xrpl.Client.Json; +using Xrpl.Models.Enums; +using Xrpl.Models.Methods; +using Xrpl.Models.Transactions; + +namespace XrplTests.Xrpl.Models +{ + /// + /// The path step `type` field is a bitmask (STPathElement in rippled) and is modelled as the + /// [Flags] enum Xrpl.Models.Enums.PathStepType. It must stay a number on the wire: + /// XrplJsonOptions deliberately registers no global JsonStringEnumConverter, because XRPL + /// protocol enums are numeric. + /// + [TestClass] + public class TestUPathStep + { + private const string MptIssuanceId = "00000001A407AF5856CCA3379B1EC94E1D2C5B99C1BE89C2"; + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathStepTypeDeserializesAsFlags() + { + // shape of mainnet tx 1D813B78FC55ABF9054AEBD2AF9DD7C90361F9985B7897E8E9A592D63BF0CC43 + string json = @"{""currency"":""4249547800000000000000000000000000000000"",""issuer"":""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"",""type"":48}"; + + Path step = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + + Assert.AreEqual(PathStepType.Currency | PathStepType.Issuer, step.Type); + Assert.IsTrue(step.Type.Value.HasFlag(PathStepType.Issuer)); + Assert.IsFalse(step.Type.Value.HasFlag(PathStepType.Account)); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathStepMptTypeDeserializesAsFlags() + { + string json = @"{""mpt_issuance_id"":""" + MptIssuanceId + @""",""issuer"":""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"",""type"":96}"; + + Path step = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + + Assert.AreEqual(MptIssuanceId, step.MPTokenIssuanceID); + Assert.AreEqual(PathStepType.MPTokenIssuanceID | PathStepType.Issuer, step.Type); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathStepTypeStaysNumericOnTheWire() + { + Path step = new Path + { + CurrencyCode = "4249547800000000000000000000000000000000", + Issuer = "rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3", + Type = PathStepType.Currency | PathStepType.Issuer, + }; + + string json = JsonSerializer.Serialize(step, XrplJsonOptions.Default); + + StringAssert.Contains(json, @"""type"":48", $"type must serialize as the number rippled sends. Got: {json}"); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathStepUndeclaredTypeBitSurvives() + { + // a future protocol bit the enum does not name must not break deserialization + Path step = JsonSerializer.Deserialize(@"{""type"":176}", XrplJsonOptions.Default); + + Assert.AreEqual(176u, (uint)step.Type.Value); + Assert.IsTrue(step.Type.Value.HasFlag(PathStepType.Issuer)); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathStepIgnoresLegacyTypeHex() + { + // rippled dropped type_hex from its JSON output in 1.7.0 and the property is gone from the + // model; a response from an ancient server must still deserialize, with the key ignored + string json = @"{""currency"":""USD"",""issuer"":""rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"",""type"":48,""type_hex"":""0000000000000030""}"; + + Path step = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + + Assert.AreEqual(PathStepType.Currency | PathStepType.Issuer, step.Type); + Assert.AreEqual("USD", step.CurrencyCode); + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathStepValidationMatchesRippledToStrand() + { + // rippled toStrand(): hasAccount && (hasIssuer || hasCurrency) -> temBAD_PATH, + // hasMPT && (hasCurrency || hasAccount) -> temBAD_PATH + Assert.IsTrue(Validation.IsPathStep(Step(("account", "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"))), "account-only step is valid"); + Assert.IsTrue(Validation.IsPathStep(Step(("currency", "USD"), ("issuer", "rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"))), "currency+issuer step is valid"); + Assert.IsTrue(Validation.IsPathStep(Step(("mpt_issuance_id", MptIssuanceId))), "MPT step is valid"); + Assert.IsTrue(Validation.IsPathStep(Step(("mpt_issuance_id", MptIssuanceId), ("issuer", "rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"))), "MPT+issuer step is valid"); + + Assert.IsFalse(Validation.IsPathStep(Step(("account", "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"), ("currency", "USD"))), "account+currency is temBAD_PATH"); + Assert.IsFalse(Validation.IsPathStep(Step(("account", "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"), ("issuer", "rBitcoiNXev8VoVxV7pwoQx1sSfonVP9i3"))), "account+issuer is temBAD_PATH"); + Assert.IsFalse(Validation.IsPathStep(Step(("account", "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"), ("mpt_issuance_id", MptIssuanceId))), "account+MPT is temBAD_PATH"); + Assert.IsFalse(Validation.IsPathStep(Step(("currency", "USD"), ("mpt_issuance_id", MptIssuanceId))), "currency+MPT is temBAD_PATH"); + Assert.IsFalse(Validation.IsPathStep(Step()), "an empty step carries no asset and no account"); + } + + private static Dictionary Step(params (string Key, object Value)[] fields) + { + Dictionary step = new Dictionary(); + foreach ((string key, object value) in fields) + { + step[key] = value; + } + return step; + } + + [TestMethod] + [TestCategory("TestU")] + public void TestUPathStepWithoutTypeIsNull() + { + Path step = JsonSerializer.Deserialize(@"{""account"":""rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn""}", XrplJsonOptions.Default); + + Assert.IsNull(step.Type); + } + } +} diff --git a/Xrpl/Models/Enums/PathStepType.cs b/Xrpl/Models/Enums/PathStepType.cs new file mode 100644 index 00000000..23ac89b4 --- /dev/null +++ b/Xrpl/Models/Enums/PathStepType.cs @@ -0,0 +1,27 @@ +using System; + +//https://xrpl.org/paths.html#path-steps +namespace Xrpl.Models.Enums +{ + /// + /// Bitmask describing which fields a path step carries, as reported by rippled in the + /// type field of every path step (STPathElement upstream).
+ /// The value is derived from the fields actually present in the step: rippled ignores it when + /// parsing a submitted transaction, and the binary codec synthesizes the byte itself.
+ /// A value carrying a bit this enum does not declare is preserved as-is on deserialization. + ///
+ [Flags] + public enum PathStepType : uint + { + /// No field present. + None = 0x00, + /// Rippling through an account (as opposed to taking an offer). + Account = 0x01, + /// A currency is present, changing the asset through an order book. + Currency = 0x10, + /// An issuer is present. + Issuer = 0x20, + /// An MPTokenIssuanceID is present (rippled 3.2.0+, MPTokensV2 amendment). + MPTokenIssuanceID = 0x40, + } +} diff --git a/Xrpl/Models/Methods/Path.cs b/Xrpl/Models/Methods/Path.cs index f9a7579b..bd8915a1 100644 --- a/Xrpl/Models/Methods/Path.cs +++ b/Xrpl/Models/Methods/Path.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Xrpl.Models.Enums; //https://github.com/XRPLF/xrpl.js/blob/b20c05c3680d80344006d20c44b4ae1c3b0ffcac/packages/xrpl/src/models/common/index.ts#L62 //https://xrpl.org/paths.html#path-steps namespace Xrpl.Models.Methods @@ -20,7 +21,8 @@ public class Path //todo rename to path steps? /// /// (Optional) If present, this path step represents changing currencies through an order book.
/// The currency specified indicates the new currency.
- /// MUST NOT be provided if this step specifies the account field. + /// MUST NOT be provided if this step specifies the account field.
+ /// MUST NOT be combined with the mpt_issuance_id field. ///
[JsonPropertyName("currency")] public string CurrencyCode { get; set; } @@ -36,16 +38,23 @@ public class Path //todo rename to path steps? public string Issuer { get; set; } /// - /// (Optional) An integer bitfield indicating which fields are present in this path step.
- /// 0x01 = account, 0x10 = currency, 0x20 = issuer. + /// (Optional) If present, this path step represents changing assets through an MPT order book.
+ /// Requires rippled 3.2.0+ with the MPTokensV2 amendment enabled.
+ /// MUST NOT be combined with the currency field. ///
- [JsonPropertyName("type")] - public int? Type { get; set; } + [JsonPropertyName("mpt_issuance_id")] + public string MPTokenIssuanceID { get; set; } /// - /// (Optional) Hex representation of the type field. + /// (Optional) A bitfield indicating which fields are present in this path step.
+ /// Serialized as the number rippled sends: 0x01 account, 0x10 currency, 0x20 issuer, + /// 0x40 mpt_issuance_id — a value the enum does not declare is preserved as-is.
+ /// The XRPL documentation marks the field as deprecated, but every rippled version still emits it on + /// every path step of every response.
+ /// Read-only in practice: the value is ignored both by rippled when it parses a submitted transaction + /// and by the binary codec, which derives the byte from the fields actually present in the step. ///
- [JsonPropertyName("type_hex")] - public string TypeHex { get; set; } + [JsonPropertyName("type")] + public PathStepType? Type { get; set; } } } diff --git a/Xrpl/Models/Transactions/Payment.cs b/Xrpl/Models/Transactions/Payment.cs index 7bbcd7f7..6338a821 100644 --- a/Xrpl/Models/Transactions/Payment.cs +++ b/Xrpl/Models/Transactions/Payment.cs @@ -342,10 +342,18 @@ public static bool IsPathStep(Dictionary pathStep) return false; if (pathStep.TryGetValue("issuer", out var issuer) && issuer is not string { }) return false; + if (pathStep.TryGetValue("mpt_issuance_id", out var mptIssuanceId) && mptIssuanceId is not string { }) + return false; - if (acc is not null && currency is null && issuer is null) + // rippled toStrand(): `hasAccount && (hasIssuer || hasCurrency)` and + // `hasMPT && (hasCurrency || hasAccount)` are both temBAD_PATH + if (currency is not null && mptIssuanceId is not null) + return false; + if (acc is not null && (currency is not null || issuer is not null || mptIssuanceId is not null)) + return false; + if (acc is not null) return true; - if (currency is not null || issuer is not null) + if (currency is not null || issuer is not null || mptIssuanceId is not null) return true; return false; } From a11ae938f4106cfcc83a13b37ecf4ebaad8bf2b2 Mon Sep 17 00:00:00 2001 From: Aleksandr <44946855+Platonenkov@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:07:37 +0000 Subject: [PATCH 12/14] =?UTF-8?q?fix(tests):=20=D0=BC=D0=BE=D0=BA-=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=B2=D0=B0=D0=BB=20=D0=BF=D1=80=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=D0=BC=D0=B0=D1=82=D1=8C=20=D1=81=D0=BE=D0=B5=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20?= =?UTF-8?q?=D0=BE=D0=B4=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BE=D0=B1=D1=80=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=20(#84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tests): мок-сервер переставал принимать соединения после одного обрыва Причина флейка TestConcurrentChangeServerKeepsClientRecoverable: unit упал на push-прогоне dev, тогда как тот же коммит в PR-прогоне прошёл. Server.connectionCallback вызывал BeginAccept последней строкой try-блока, поэтому любое исключение выше по телу навсегда обрывало приём соединений. Пир, рвущий соединение во время handshake — ровно то, что порождают конкурентные ChangeServer/Disconnect — глушил мок именно так. Со стороны всё выглядело исправно: слушающий сокет оставался связан, порт числился занятым, TCP-соединения устанавливались ядром, и клиент видел сервер, который принял подключение и молчит. Отсюда 2m06s у упавшего прогона против 10s у обычного и сообщение, обвиняющее клиент в том, что он «не дошёл до живого сервера». - BeginAccept перезапускается безусловно после колбэка, чем бы тот ни кончился; ObjectDisposedException обрабатывается отдельно и без перезапуска — он означает, что Stop() закрыл listener - сокет, чей handshake упал, закрывается, а не течёт до конца процесса - TestUMockRippledAcceptLoop фиксирует поведение: обрыв на handshake (RST через LingerOption(true, 0)) поодиночке и десять подряд, затем обычный клиент, который обязан подключиться. На старом колбэке оба падают, после фикса проходят за 0.6s - TestUtils.MockCompletesHandshake: проба реальным WS-upgrade. Упавший reconnect-тест теперь сообщает, отвечает ли мок, — глухой сервер больше не будет прочитан как баг клиента. Проба покрыта на живом моке, свободном порту и слушающем сокете, который никогда не принимает * docs(changes): убрать запись о фиксе мок-сервера из changelog Правка чисто в тестовой инфраструктуре: код SDK не менялся, потребителю пакета изменение не видно, поэтому в changelog релиза ему места нет. Разбор причины остаётся в описании PR и в комментариях к самому коду. --- .../Client/TestUMockRippledAcceptLoop.cs | 194 ++++++++++++++++++ .../Client/TestUReconnectSessionRaces.cs | 17 +- Tests/Xrpl.Tests/MockRippled/Server.cs | 48 ++++- Tests/Xrpl.Tests/TestUtils.cs | 47 ++++- 4 files changed, 293 insertions(+), 13 deletions(-) create mode 100644 Tests/Xrpl.Tests/Client/TestUMockRippledAcceptLoop.cs diff --git a/Tests/Xrpl.Tests/Client/TestUMockRippledAcceptLoop.cs b/Tests/Xrpl.Tests/Client/TestUMockRippledAcceptLoop.cs new file mode 100644 index 00000000..8cabf8f2 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestUMockRippledAcceptLoop.cs @@ -0,0 +1,194 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; + +using Xrpl.Client; + +namespace Xrpl.Tests +{ + /// + /// Guards the mock server's accept loop against a single bad connection taking it down. + /// + /// + /// The mock re-arms BeginAccept as the last statement of its accept callback, so any + /// throw earlier in that callback — a peer that resets the connection before or during the + /// WebSocket handshake — used to end the loop for good. The listen socket stayed bound, so the + /// port still looked taken and connects still completed at the TCP level, but nothing was ever + /// accepted again: every later client hung until its own connect timeout. + /// + /// That is not a hypothetical. It is what made flaky + /// on CI: concurrent ChangeServer/Disconnect calls abort half-open connections, one of those + /// aborts silenced the mock, and the assertion at the end of the test then blamed the client + /// for not reaching "a server that is up" — while the server was in fact deaf. + /// + [TestClass] + public class TestUMockRippledAcceptLoop + { + private CreateMockRippled _mock; + private int _port; + + private static Dictionary ServerInfoResponse() => new Dictionary + { + { "type", "response" }, + { "status", "success" }, + { "result", new Dictionary + { + { "info", new Dictionary + { + { "build_version", "test-mock" }, + { "complete_ledgers", "1-1" }, + { "server_state", "full" }, + } + }, + } + }, + }; + + [TestInitialize] + public void MyTestInitialize() + { + _port = TestUtils.GetFreePort(); + _mock = new CreateMockRippled(_port) { suppressOutput = true }; + _mock.AddResponse("server_info", ServerInfoResponse()); + _mock.Start(); + } + + [TestCleanup] + public void MyTestCleanup() => _mock?.Stop(); + + /// + /// Resets a connection while the mock is in its accept callback, then requires the mock to + /// still serve the next client. + /// + [TestMethod] + public async Task TestAbortedHandshakeLeavesTheMockAccepting() + { + // A zero linger time makes Close() send RST rather than FIN, so the mock's blocking + // Receive of the handshake fails with "connection reset by peer" — the exact throw + // seen in the CI log of the flaky run. + using (Socket rude = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)) + { + rude.LingerState = new LingerOption(true, 0); + await rude.ConnectAsync(IPAddress.Loopback, _port); + rude.Close(); + } + + // Give the mock a moment to run its callback and (before the fix) fall out of it. + await Task.Delay(TimeSpan.FromMilliseconds(200)); + + XrplClient client = new XrplClient($"ws://127.0.0.1:{_port}", new XrplClient.ClientOptions + { + RequestPolicy = RequestFailurePolicy.ImmediateFail, + ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(5), + ConnectionAttemptTimeout = TimeSpan.FromSeconds(3), + UseCustomPing = false, + }); + + try + { + await client.Connect(); + + Assert.IsTrue( + client.connection.IsConnected(), + "The mock stopped accepting after one aborted connection — its accept loop was not re-armed."); + } + finally + { + try + { + await client.Disconnect(); + } + catch (Exception) + { + // Cleanup must not mask the assertion above. + } + } + } + + /// + /// The handshake probe the reconnect tests use to attribute a failure must actually tell + /// a serving mock from a deaf one — a probe that always says "alive" would be worse than + /// none, since it would confirm the wrong suspect. + /// + [TestMethod] + public void TestHandshakeProbeTellsAServingMockFromADeafOne() + { + TimeSpan timeout = TimeSpan.FromSeconds(2); + + Assert.IsTrue( + TestUtils.MockCompletesHandshake(_port, timeout), + "A running mock must answer the probe with a 101 upgrade."); + + Assert.IsFalse( + TestUtils.MockCompletesHandshake(TestUtils.GetFreePort(), timeout), + "Nothing listens on that port, so the probe must report it as not serving."); + + // The case the probe exists for: a socket that is bound and listening but never + // accepts. The TCP connect still succeeds — which is why a plain connect check proves + // nothing — and only the missing handshake reveals that the server is deaf. + TcpListener deaf = new TcpListener(IPAddress.Loopback, 0); + deaf.Start(); + try + { + int deafPort = ((IPEndPoint)deaf.LocalEndpoint).Port; + Assert.IsFalse( + TestUtils.MockCompletesHandshake(deafPort, timeout), + "A listening socket that never accepts must be reported as not serving."); + } + finally + { + deaf.Stop(); + } + } + + /// + /// The same guarantee under repetition: a run of aborted connections must not degrade the + /// mock, since the reconnect tests abort several in a row. + /// + [TestMethod] + public async Task TestRepeatedAbortedHandshakesLeaveTheMockAccepting() + { + for (int i = 0; i < 10; i++) + { + using Socket rude = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + rude.LingerState = new LingerOption(true, 0); + await rude.ConnectAsync(IPAddress.Loopback, _port); + rude.Close(); + } + + await Task.Delay(TimeSpan.FromMilliseconds(200)); + + XrplClient client = new XrplClient($"ws://127.0.0.1:{_port}", new XrplClient.ClientOptions + { + RequestPolicy = RequestFailurePolicy.ImmediateFail, + ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(5), + ConnectionAttemptTimeout = TimeSpan.FromSeconds(3), + UseCustomPing = false, + }); + + try + { + await client.Connect(); + + Assert.IsTrue( + client.connection.IsConnected(), + "The mock stopped accepting after a run of aborted connections."); + } + finally + { + try + { + await client.Disconnect(); + } + catch (Exception) + { + // Cleanup must not mask the assertion above. + } + } + } + } +} diff --git a/Tests/Xrpl.Tests/Client/TestUReconnectSessionRaces.cs b/Tests/Xrpl.Tests/Client/TestUReconnectSessionRaces.cs index 1ce93c07..56a8e4ed 100644 --- a/Tests/Xrpl.Tests/Client/TestUReconnectSessionRaces.cs +++ b/Tests/Xrpl.Tests/Client/TestUReconnectSessionRaces.cs @@ -1,4 +1,4 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Collections.Generic; @@ -161,10 +161,17 @@ public async Task TestConcurrentChangeServerKeepsClientRecoverable() await Task.Delay(TimeSpan.FromMilliseconds(100)); } - Assert.IsTrue( - _client.connection.IsConnected(), - "After concurrent ChangeServer calls the client could not reach a server that is up — " + - "the reconnect session was left disposed or orphaned."); + if (!_client.connection.IsConnected()) + { + // Say which side failed. A mock whose accept loop died still holds the port, so + // "the server is up" is an assumption worth checking before blaming the client — + // this test spent a CI run being read as a client bug for exactly that reason. + bool mockServing = TestUtils.MockCompletesHandshake(_port, TimeSpan.FromSeconds(2)); + Assert.Fail( + "After concurrent ChangeServer calls the client could not reach a server that is up — " + + "the reconnect session was left disposed or orphaned. " + + $"(mock still completes a handshake: {mockServing})"); + } } /// diff --git a/Tests/Xrpl.Tests/MockRippled/Server.cs b/Tests/Xrpl.Tests/MockRippled/Server.cs index 4f8fdef5..9af307f5 100644 --- a/Tests/Xrpl.Tests/MockRippled/Server.cs +++ b/Tests/Xrpl.Tests/MockRippled/Server.cs @@ -254,10 +254,14 @@ public void Stop() /// The async operation state private void connectionCallback(IAsyncResult AsyncResult) { + // Held until ownership passes to the MockClient, so a handshake that throws + // half-way closes the socket instead of leaking it for the process lifetime. + Socket clientSocket = null; + try { // Gets the client thats trying to connect to the server - Socket clientSocket = GetSocket().EndAccept(AsyncResult); + clientSocket = GetSocket().EndAccept(AsyncResult); // Read the handshake updgrade request byte[] handshakeBuffer = new byte[1024]; @@ -267,25 +271,55 @@ private void connectionCallback(IAsyncResult AsyncResult) string requestKey = Helpers.GetHandshakeRequestKey(Encoding.Default.GetString(handshakeBuffer)); string hanshakeResponse = Helpers.GetHandshakeResponse(Helpers.HashKey(requestKey)); - // Send the handshake updgrade response to the connecting client + // Send the handshake updgrade response to the connecting client clientSocket.Send(Encoding.Default.GetBytes(hanshakeResponse)); - // Create a new client object and add + // Create a new client object and add // it to the list of connected clients MockClient client = new MockClient(this, clientSocket); + clientSocket = null; _clients.Add(client); - // Call the event when a client has connected to the listen server + // Call the event when a client has connected to the listen server if (OnClientConnected == null) throw new Exception("Server error: event OnClientConnected is not bound!"); OnClientConnected(this, new OnClientConnectedHandler(client)); + } + catch (ObjectDisposedException) + { + // Stop() closed the listen socket: nothing left to accept on, and re-arming + // below would only throw again. + return; + } + catch (Exception Exception) + { + Debug.WriteLine("An error has occured while trying to accept a connecting client.\n\n{0}", Exception.Message); - // Start to accept incomming connections again - GetSocket().BeginAccept(connectionCallback, null); + try + { + clientSocket?.Close(); + } + catch (Exception) + { + // The peer is already gone; nothing to salvage. + } + } + // Re-arm unconditionally. This call used to be the last statement of the try block, + // so a peer that reset the connection during the handshake ended the accept loop for + // good: the listen socket stayed bound — the port still looked taken and TCP connects + // still completed — while nothing was ever accepted again, and every later client hung + // until its own connect timeout. One bad connection must not deafen the mock. + try + { + GetSocket().BeginAccept(connectionCallback, null); + } + catch (ObjectDisposedException) + { + // Stop() ran while this callback was in flight. } catch (Exception Exception) { - Debug.WriteLine("An error has occured while trying to accept a connecting client.\n\n{0}", Exception.Message); + Debug.WriteLine("An error has occured while re-arming the accept loop.\n\n{0}", Exception.Message); } } diff --git a/Tests/Xrpl.Tests/TestUtils.cs b/Tests/Xrpl.Tests/TestUtils.cs index 746e7d2c..a0fdf567 100644 --- a/Tests/Xrpl.Tests/TestUtils.cs +++ b/Tests/Xrpl.Tests/TestUtils.cs @@ -1,10 +1,11 @@ - + // https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/test/testUtils.ts using System; using System.Collections.Concurrent; using System.Net; using System.Net.Sockets; +using System.Text; namespace Xrpl.Tests { @@ -48,6 +49,50 @@ static public int GetFreePort() "GetFreePort: could not obtain an unclaimed loopback port after 50 attempts"); } + /// + /// Whether a mock server on still completes a WebSocket handshake. + /// + /// + /// A dead accept loop leaves the listen socket bound, so a plain TCP connect still + /// succeeds and proves nothing — only an answered handshake shows the mock is serving. + /// Tests use this to say whether a connection failure was the client's doing or the + /// mock's, instead of blaming the client for a server that went deaf. + /// + static public bool MockCompletesHandshake(int port, TimeSpan timeout) + { + try + { + using TcpClient probe = new TcpClient(); + if (!probe.ConnectAsync(IPAddress.Loopback, port).Wait(timeout)) + { + return false; + } + + NetworkStream stream = probe.GetStream(); + stream.WriteTimeout = (int)timeout.TotalMilliseconds; + stream.ReadTimeout = (int)timeout.TotalMilliseconds; + + // The mock reads the key by offset from "Sec-WebSocket-Key: ", so the header must + // carry a full 24-character value like a real client sends. + byte[] request = Encoding.ASCII.GetBytes( + "GET / HTTP/1.1\r\n" + + "Host: 127.0.0.1\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + + "Sec-WebSocket-Version: 13\r\n\r\n"); + stream.Write(request, 0, request.Length); + + byte[] buffer = new byte[256]; + int read = stream.Read(buffer, 0, buffer.Length); + return read > 0 && Encoding.ASCII.GetString(buffer, 0, read).Contains("101"); + } + catch (Exception) + { + return false; + } + } + /// /// Whether can still be bound on loopback right now. Tests that /// hold a port across an await use this to fail fast with a clear reason instead of From 5e6ed6e200a6f23afe67d5b06762211fc947a59e Mon Sep 17 00:00:00 2001 From: Aleksandr <44946855+Platonenkov@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:08:48 +0000 Subject: [PATCH 13/14] =?UTF-8?q?perf(json):=20=D0=BA=D1=8D=D1=88=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B8=D0=B7=D0=B2=D0=BE=D0=B4=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?JsonSerializerOptions=20=D0=B2=20=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=82=D0=B5=D1=80=D0=B0=D1=85=20+=20=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D1=81=20=D0=BD=D0=B5=D0=B8=D0=B7=D0=B2=D0=B5=D1=81=D1=82=D0=BD?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20LedgerEntryType=20(#85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(json): кэш производных JsonSerializerOptions + фикс неизвестного LedgerEntryType Полиморфные конвертеры (LOConverter, оба конвертера транзакций, MetaBinaryConverter, LedgerBinaryConverter, LONFTokenConverter, GenericStringConverter) заново входят в сериализатор с удалённым собственным конвертером, и каждый вызов строил производные JsonSerializerOptions с нуля. System.Text.Json кэширует метаданные типов per-instance, а копирующий конструктор кэш не переносит — страница account_objects на 200 записей перестраивала контракты 200 раз. JsonSerializerOptionsCache строит их один раз на пару (исходные options, тип конвертера), слабо ключуясь по исходному экземпляру. Безопасно: STJ замораживает options при первом использовании, поэтому то, что конвертер получает на вход, измениться уже не может. Попутно — LOConverter.DetermineType разрешал неизвестный LedgerEntryType в LOAccountRoot: Enum.TryParse при неудаче пишет в out default(TEnum), а AccountRoot — нулевое значение, и инициализация Unknown затиралась. Тип объекта новее SDK десериализовался как account root с молча потерянными полями вместо отката к BaseLedgerEntry. Снят //todo change from class to interface на AccountObjects.AccountObjectList: парсинг работает как в transactionResponse с момента глобальной регистрации LOConverter, а BaseLedgerEntry обязан оставаться конкретным классом именно как fallback для Unknown. Тесты: TestUJsonSerializerOptionsCache (переиспользование экземпляра, изоляция по источнику и типу конвертера), TestUAccountObjectsPolymorphism (разнотипная страница account_objects, round-trip, неизвестный тип, страница на 200 записей), два теста в TestULOConverter. * docs(json): уточнить механизм выигрыша от кэша и усилить его тесты Селф-ревью показал, что заявленное обоснование неверно для .NET 8+. Пробник-конвертер, записывающий полученный экземпляр options при разборе списка, видит один и тот же экземпляр на всех элементах даже при копии-на-вызов: с .NET 8 STJ разделяет caching-context между структурно равными options, поэтому метаданные типов не перестраивались. Настоящая цена копии — аллокация, копирование списка конвертеров и структурный поиск в пуле контекстов на каждое конвертируемое значение. Оптимизация остаётся: 200 страниц account_objects по 200 записей — 456 мс / 47 МБ до, 217 мс / 29 МБ после; плюс снимается зависимость от пула контекстов, ограниченного 64 записями. Формулировки в XML-doc и CHANGES.md приведены к измеренному. Deserialize_ReusesTheCachedOptionsInstance не проверял заявленного: обе стороны сравнения читали кэш напрямую, тест оставался зелёным при откате LOConverter.Read на копию-на-вызов. Заменён на Deserialize_GoesThroughTheCache — приватный экземпляр options, которого не касается ни один другой тест, и проверка появления записи в кэше через internal HasCachedEntry. Откат конвертера теперь роняет тест (проверено). Добавлен WithoutConverter_LeavesTheSourceOptionsIntact: если Build когда-нибудь начнёт снимать конвертер с исходного экземпляра вместо копии, XrplJsonOptions.Default потеряет LOConverter на весь процесс, и раньше этого не поймал бы ни один тест класса. * docs(json): убрать устаревший механизм из summary тестов кэша options --- CHANGES.md | 4 + .../AccountObjectsPolymorphismTests.cs | 183 ++++++++++++++++++ .../Json/Converters/LOConverterTests.cs | 28 +++ .../Json/JsonSerializerOptionsCacheTests.cs | 140 ++++++++++++++ .../Json/Converters/GenericStringConverter.cs | 7 +- .../Json/Converters/LONFTokenConverter.cs | 3 +- .../Json/Converters/LedgerBinaryConverter.cs | 6 +- .../Json/Converters/LedgerObjectConverter.cs | 28 +-- .../Json/Converters/MetaBinaryConverter.cs | 6 +- .../Converters/TransactionRequestConverter.cs | 10 +- .../TransactionResponseConverter.cs | 14 +- .../Client/Json/JsonSerializerOptionsCache.cs | 73 +++++++ Xrpl/Models/Methods/AccountObjects.cs | 8 +- Xrpl/Xrpl.csproj | 3 + 14 files changed, 455 insertions(+), 58 deletions(-) create mode 100644 Tests/Xrpl.Tests/Client/Json/Converters/AccountObjectsPolymorphismTests.cs create mode 100644 Tests/Xrpl.Tests/Client/Json/JsonSerializerOptionsCacheTests.cs create mode 100644 Xrpl/Client/Json/JsonSerializerOptionsCache.cs diff --git a/CHANGES.md b/CHANGES.md index 0ae70c4a..1fb655ad 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -36,6 +36,10 @@ * **Repeated `OnConnected` handler failures now back off** — the give-up branch added earlier is bounded by `MaxReconnectAttempts`, but only when `StopAfterMaxAttempts` is set. With it turned off there is no give-up at all, and the delay between retries never grew: this path tears the reconnect loop down and starts it again on every failure, `StopReconnectLoop` zeroes `_reconnectAttempts`, a fresh sequence zeroes it again, and `CalcBackoff` derives the delay from that counter alone. The client therefore repeated connect → handler failure → teardown at a constant `ReconnectBaseDelay` forever — a sustained connection load on exactly the node that cannot serve requests yet. `StartReconnectLoop` now takes the value to seed the counter with, and the handler-failure path seeds it from its own consecutive-failure count so the sequence keeps growing across failures. `TestRepeatedOnConnectedFailuresBackOff` pins it; reverting the fix makes that test show ~100 reconnects in 20s at a flat ~200ms interval * **`_reconnectCts` is `volatile`** — the reconnect loop compares it by reference to decide whether it still owns the reconnect state, while `StopReconnectLoop`, `StartReconnectLoop` and `RetireCurrentSessionAndReconnectAsync` write it from other threads. A stale read could let a retired loop run one more iteration or make the owning loop stand down early. The other cross-thread fields in that class were already `volatile` * **Lending guide corrected** — the `Loan Fields` table in `LendingProtocol-Guide` (both languages) listed four names the ledger object does not have: `Account` (the borrower is in `Borrower`), plus `Counterparty`, `PrincipalRequested` and `PaymentTotal`, which are fields of the **`LoanSet` transaction**. After `PrincipalRequested` was removed from `LOLoan` in this release the guide would have promised a property that no longer exists. Fixed, with a note pointing the three transaction fields at `LoanSet` +* **JSON serialization: the derived converter options are cached, and unknown ledger-object types no longer read as AccountRoot** — every polymorphic converter (`LOConverter`, both transaction converters, `MetaBinaryConverter`, `LedgerBinaryConverter`, `LONFTokenConverter`, `GenericStringConverter`) re-enters the serializer with its own converter removed to break the recursion, and each call built that derived `JsonSerializerOptions` from scratch — an allocation, a copy of the whole converter list and a structural-equality lookup in System.Text.Json's caching-context pool, per converted value, so once per element of a page. Type metadata was not rebuilt each time: since .NET 8 System.Text.Json shares a caching context between structurally equal options instances, which is what kept the per-call copy from being far worse than it was — measured on 200 `account_objects` pages of 200 entries, 456 ms / 47 MB allocated before against 217 ms / 29 MB after. `JsonSerializerOptionsCache` builds the derived options once per (source options, converter type), keyed weakly on the source so caller-supplied options stay collectable — safe because System.Text.Json freezes an options instance on first use, so what a converter is handed can no longer change. It also drops the reliance on that context pool, which is capped at 64 entries: + * `LOConverter.DetermineType` resolved an unrecognized `LedgerEntryType` to **`LOAccountRoot`**. `Enum.TryParse` writes `default(TEnum)` into its `out` on failure and `AccountRoot` is the zero value, overwriting the `Unknown` the variable was initialized with. A ledger object type newer than the SDK was therefore deserialized as an account root with every field silently dropped, instead of falling back to `BaseLedgerEntry` the way `LedgerEntryTypeConverter` and `NodeConverterBase` already do. Pinned from both entry points — a bare `BaseLedgerEntry` and an `account_objects` page + * the `//todo change from class to interface and parse same as transactionResponse` on `AccountObjects.AccountObjectList` is dropped rather than implemented. The parsing half has been true since `LOConverter` was registered globally, and `BaseLedgerEntry` has to stay a concrete class precisely because it is the `Unknown` fallback — an interface would need a sentinel type, which is what `TransactionResponseUnknown` exists to be. Nothing pinned the polymorphism for the response model itself; `TestUAccountObjectsPolymorphism` now does + * **Test-side fixes** — `TestUtils.GetFreePort` never handed out a port twice within the process (the OS is free to return a just-released port, and test classes run in parallel, so two callers could get the same one and the second mock would fail to bind on its background thread, surfacing as a timeout rather than an error); `TestUChangeServerFailure` checks the port is still free right before starting the second mock, so the remaining external race fails fast with a clear message; `RippledLedgerFlags.Parse` throws on a ledger object declared twice, matching `RippledLedgerEntryFormats.Parse`; the fixture entries in the test `.csproj` use `None Update` instead of `None Include`, since the SDK's default glob already includes them * **`TestULedgerEntryFieldsConformance` — the third conformance surface**, completing the set next to `TestUTxFormatConformance` (transaction fields) and `TestULedgerFlagsConformance` (ledger flags). `ledger_entries.macro` is the only place the protocol states which fields belong to which ledger object — `definitions.json` carries field codes and object types but not the per-object lists — and nothing checked it. A missing field produces no symptom: reading the object still succeeds and the value is silently dropped, which is how `LOAccountRoot` went without `WalletLocator`/`WalletSize` until a manual pass, and how `sfLEVersion` had to arrive through a protocol-watch notification instead of a red test: diff --git a/Tests/Xrpl.Tests/Client/Json/Converters/AccountObjectsPolymorphismTests.cs b/Tests/Xrpl.Tests/Client/Json/Converters/AccountObjectsPolymorphismTests.cs new file mode 100644 index 00000000..a7026935 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/Json/Converters/AccountObjectsPolymorphismTests.cs @@ -0,0 +1,183 @@ +using System.Text.Json; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Client.Json; +using Xrpl.Models; +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; + +namespace XrplTests.Client.Json.Converters; + +/// +/// account_objects returns a heterogeneous array — every element carries its own LedgerEntryType. +/// LOConverter is registered globally, so the elements of List<BaseLedgerEntry> are resolved to the +/// concrete LO* types; nothing but these tests pins that for the response model itself. +/// +[TestClass] +public class TestUAccountObjectsPolymorphism +{ + private static readonly JsonSerializerOptions Options = XrplJsonOptions.Default; + + private const string MixedResponse = @"{ + ""account"": ""r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59"", + ""account_objects"": [ + { + ""LedgerEntryType"": ""RippleState"", + ""Balance"": {""currency"": ""USD"", ""issuer"": ""rrrrrrrrrrrrrrrrrrrrBZbvji"", ""value"": ""-16.005""}, + ""Flags"": 131072, + ""HighLimit"": {""currency"": ""USD"", ""issuer"": ""r9cZA1mLK5R5Am25ArfXFmqgNwjZgnfk59"", ""value"": ""5000""}, + ""LowLimit"": {""currency"": ""USD"", ""issuer"": ""rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B"", ""value"": ""0""}, + ""PreviousTxnID"": ""CFFF5CFE623C9543308C6529782B6A6532207D819795AAFE85555DB8BF390FE7"", + ""PreviousTxnLgrSeq"": 14365854, + ""index"": ""826CF5BFD28F3934B518D0BDF3231259CBD3FD0946E3C3CA0C97D2C75D2D1A09"" + }, + { + ""LedgerEntryType"": ""Check"", + ""Account"": ""rUn84CJZe1swmzfnRMHPBmTGVsQFhLtLTb"", + ""Destination"": ""rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy"", + ""SendMax"": ""100000000"", + ""Sequence"": 2, + ""PreviousTxnLgrSeq"": 8010340, + ""index"": ""49647F0D748DC3FE26BDACBC57F251AADEFFF391403EC9BF87C97F67E9977FB0"" + }, + { + ""LedgerEntryType"": ""Escrow"", + ""Account"": ""rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"", + ""Destination"": ""ra5nK24KXen9AHvsdFTKHSANinZseWnPcX"", + ""Amount"": ""10000"", + ""PreviousTxnLgrSeq"": 14328672, + ""index"": ""DC5F3851D8A1AB622F957761E5963BC5BD439D5C24AC6AD7AC4523F0640244AC"" + }, + { + ""LedgerEntryType"": ""SignerList"", + ""Flags"": 0, + ""OwnerNode"": ""0000000000000000"", + ""SignerQuorum"": 3, + ""index"": ""A9C28A28B85CD533217F5C0A0C7767666B093FA58A0F2D80026FCC4CD932DDC7"" + } + ], + ""ledger_hash"": ""053DF17D2289D1C4971C22F235BC1FCA7D4B3AE966F842E5819D0749E0B8ECD3"", + ""ledger_index"": 14378733, + ""validated"": true + }"; + + [TestMethod] + public void Deserialize_MixedAccountObjects_ResolvesEachElementToItsConcreteType() + { + AccountObjects response = JsonSerializer.Deserialize(MixedResponse, Options); + + Assert.IsNotNull(response); + Assert.HasCount(4, response.AccountObjectList); + + Assert.IsInstanceOfType(response.AccountObjectList[0], typeof(LORippleState)); + Assert.IsInstanceOfType(response.AccountObjectList[1], typeof(LOCheck)); + Assert.IsInstanceOfType(response.AccountObjectList[2], typeof(LOEscrow)); + Assert.IsInstanceOfType(response.AccountObjectList[3], typeof(LOSignerList)); + } + + [TestMethod] + public void Deserialize_MixedAccountObjects_KeepsSubtypeFields() + { + AccountObjects response = JsonSerializer.Deserialize(MixedResponse, Options); + + LORippleState state = (LORippleState)response.AccountObjectList[0]; + Assert.AreEqual("CFFF5CFE623C9543308C6529782B6A6532207D819795AAFE85555DB8BF390FE7", state.PreviousTxnID); + Assert.AreEqual("USD", state.Balance.CurrencyCode); + Assert.AreEqual("5000", state.HighLimit.Value); + + LOCheck check = (LOCheck)response.AccountObjectList[1]; + Assert.AreEqual("rUn84CJZe1swmzfnRMHPBmTGVsQFhLtLTb", check.Account); + Assert.AreEqual("rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy", check.Destination); + + LOEscrow escrow = (LOEscrow)response.AccountObjectList[2]; + Assert.AreEqual("rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", escrow.Account); + + LOSignerList signerList = (LOSignerList)response.AccountObjectList[3]; + Assert.AreEqual(3u, signerList.SignerQuorum); + } + + [TestMethod] + public void Deserialize_MixedAccountObjects_SetsLedgerEntryTypeAndIndex() + { + AccountObjects response = JsonSerializer.Deserialize(MixedResponse, Options); + + Assert.AreEqual(LedgerEntryType.RippleState, response.AccountObjectList[0].LedgerEntryType); + Assert.AreEqual(LedgerEntryType.Check, response.AccountObjectList[1].LedgerEntryType); + Assert.AreEqual(LedgerEntryType.Escrow, response.AccountObjectList[2].LedgerEntryType); + Assert.AreEqual(LedgerEntryType.SignerList, response.AccountObjectList[3].LedgerEntryType); + + Assert.AreEqual( + "826CF5BFD28F3934B518D0BDF3231259CBD3FD0946E3C3CA0C97D2C75D2D1A09", + response.AccountObjectList[0].Index); + } + + /// + /// A ledger object type the SDK does not know must not throw — it falls back to the base entry. + /// This is why BaseLedgerEntry stays a concrete class. + /// + [TestMethod] + public void Deserialize_UnknownLedgerEntryType_FallsBackToBaseLedgerEntry() + { + string json = @"{ + ""account"": ""rTest"", + ""account_objects"": [ + { ""LedgerEntryType"": ""SomethingRippledAddedLater"", ""Whatever"": 1, ""index"": ""AABB"" } + ] + }"; + + AccountObjects response = JsonSerializer.Deserialize(json, Options); + + Assert.HasCount(1, response.AccountObjectList); + BaseLedgerEntry entry = response.AccountObjectList[0]; + Assert.AreEqual(typeof(BaseLedgerEntry), entry.GetType()); + Assert.AreEqual(LedgerEntryType.Unknown, entry.LedgerEntryType); + Assert.AreEqual("AABB", entry.Index); + } + + [TestMethod] + public void Serialize_MixedAccountObjects_WritesConcreteTypeFields() + { + AccountObjects response = JsonSerializer.Deserialize(MixedResponse, Options); + + string json = JsonSerializer.Serialize(response, Options); + + Assert.Contains("\"LedgerEntryType\":\"RippleState\"", json); + Assert.Contains("\"LedgerEntryType\":\"Check\"", json); + Assert.Contains("rUn84CJZe1swmzfnRMHPBmTGVsQFhLtLTb", json); + + AccountObjects roundTrip = JsonSerializer.Deserialize(json, Options); + Assert.IsInstanceOfType(roundTrip.AccountObjectList[0], typeof(LORippleState)); + Assert.IsInstanceOfType(roundTrip.AccountObjectList[1], typeof(LOCheck)); + } + + /// + /// The list path is the one that used to build a fresh JsonSerializerOptions per element: + /// a full page must resolve every entry, not just the first. + /// + [TestMethod] + public void Deserialize_LargeAccountObjectsPage_ResolvesEveryElement() + { + System.Text.StringBuilder builder = new System.Text.StringBuilder(); + builder.Append(@"{""account"":""rTest"",""account_objects"":["); + for (int i = 0; i < 200; i++) + { + if (i > 0) builder.Append(','); + builder.Append(@"{""LedgerEntryType"":""Offer"",""Account"":""rTest"",""Sequence"":") + .Append(i) + .Append(@",""index"":""") + .Append(i.ToString("X64")) + .Append(@"""}"); + } + builder.Append("]}"); + + AccountObjects response = JsonSerializer.Deserialize(builder.ToString(), Options); + + Assert.HasCount(200, response.AccountObjectList); + for (int i = 0; i < 200; i++) + { + Assert.IsInstanceOfType(response.AccountObjectList[i], typeof(LOOffer)); + Assert.AreEqual((uint)i, ((LOOffer)response.AccountObjectList[i]).Sequence); + } + } +} diff --git a/Tests/Xrpl.Tests/Client/Json/Converters/LOConverterTests.cs b/Tests/Xrpl.Tests/Client/Json/Converters/LOConverterTests.cs index 8639201a..82396630 100644 --- a/Tests/Xrpl.Tests/Client/Json/Converters/LOConverterTests.cs +++ b/Tests/Xrpl.Tests/Client/Json/Converters/LOConverterTests.cs @@ -201,4 +201,32 @@ public void Read_PermissionedDomain_ReturnsLOPermissionedDomain() Assert.IsNotNull(result); Assert.IsInstanceOfType(result, typeof(LOPermissionedDomain)); } + + /// + /// A ledger object type this SDK does not know must fall back to the base entry. Enum.TryParse + /// writes default(LedgerEntryType) — AccountRoot — when it fails, so the fallback is easy to lose. + /// + [TestMethod] + public void Read_UnknownLedgerEntryType_ReturnsBaseLedgerEntry() + { + string json = @"{ + ""LedgerEntryType"": ""SomethingRippledAddedLater"", + ""Owner"": ""rOwner"", + ""index"": ""AABB"" + }"; + BaseLedgerEntry result = JsonSerializer.Deserialize(json, Options); + Assert.IsNotNull(result); + Assert.AreEqual(typeof(BaseLedgerEntry), result.GetType()); + Assert.AreEqual(LedgerEntryType.Unknown, result.LedgerEntryType); + Assert.AreEqual("AABB", result.Index); + } + + [TestMethod] + public void Read_MissingLedgerEntryType_ReturnsBaseLedgerEntry() + { + string json = @"{ ""Owner"": ""rOwner"" }"; + BaseLedgerEntry result = JsonSerializer.Deserialize(json, Options); + Assert.IsNotNull(result); + Assert.AreEqual(typeof(BaseLedgerEntry), result.GetType()); + } } diff --git a/Tests/Xrpl.Tests/Client/Json/JsonSerializerOptionsCacheTests.cs b/Tests/Xrpl.Tests/Client/Json/JsonSerializerOptionsCacheTests.cs new file mode 100644 index 00000000..d32bce47 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/Json/JsonSerializerOptionsCacheTests.cs @@ -0,0 +1,140 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Client.Json; +using Xrpl.Client.Json.Converters; +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; + +namespace XrplTests.Client.Json; + +/// +/// Polymorphic converters re-enter the serializer with their own converter removed. Building those +/// options per call allocated an options instance, copied the whole converter list and ran its own +/// structural-equality lookup in System.Text.Json's caching-context pool — for every value converted. +/// Type metadata was not rebuilt: since .NET 8 that pool shares one caching context between +/// structurally equal options instances. The cache does the work once per (source options, converter type). +/// +[TestClass] +public class TestUJsonSerializerOptionsCache +{ + [TestMethod] + public void WithoutConverter_SameSourceAndConverter_ReturnsSameInstance() + { + JsonSerializerOptions source = XrplJsonOptions.Default; + + JsonSerializerOptions first = JsonSerializerOptionsCache.WithoutConverter(source); + JsonSerializerOptions second = JsonSerializerOptionsCache.WithoutConverter(source); + + Assert.AreSame(first, second); + Assert.AreNotSame(source, first); + } + + [TestMethod] + public void WithoutConverter_RemovesOnlyTheRequestedConverter() + { + JsonSerializerOptions derived = JsonSerializerOptionsCache.WithoutConverter(XrplJsonOptions.Default); + + foreach (JsonConverter converter in derived.Converters) + Assert.IsNotInstanceOfType(converter, typeof(LOConverter)); + + bool keptOthers = false; + foreach (JsonConverter converter in derived.Converters) + { + if (converter is TransactionResponseConverter) + keptOthers = true; + } + + Assert.IsTrue(keptOthers, "Converters unrelated to the requested type must survive"); + } + + /// + /// The source must be copied, never stripped in place: mutating it would remove LOConverter from the + /// process-wide default options and every ledger object would silently degrade to a bare entry. + /// + [TestMethod] + public void WithoutConverter_LeavesTheSourceOptionsIntact() + { + JsonSerializerOptionsCache.WithoutConverter(XrplJsonOptions.Default); + + bool sourceStillHasIt = false; + foreach (JsonConverter converter in XrplJsonOptions.Default.Converters) + { + if (converter is LOConverter) + sourceStillHasIt = true; + } + + Assert.IsTrue(sourceStillHasIt, "XrplJsonOptions.Default must keep its LOConverter"); + } + + [TestMethod] + public void WithoutConverter_PreservesSourceSettings() + { + JsonSerializerOptions source = XrplJsonOptions.Default; + JsonSerializerOptions derived = JsonSerializerOptionsCache.WithoutConverter(source); + + Assert.AreEqual(source.DefaultIgnoreCondition, derived.DefaultIgnoreCondition); + Assert.AreEqual(source.PropertyNameCaseInsensitive, derived.PropertyNameCaseInsensitive); + Assert.AreEqual(source.NumberHandling, derived.NumberHandling); + } + + [TestMethod] + public void WithoutConverter_DifferentConverterTypes_ReturnDifferentInstances() + { + JsonSerializerOptions source = XrplJsonOptions.Default; + + JsonSerializerOptions withoutLo = JsonSerializerOptionsCache.WithoutConverter(source); + JsonSerializerOptions withoutTx = JsonSerializerOptionsCache.WithoutConverter(source); + + Assert.AreNotSame(withoutLo, withoutTx); + } + + [TestMethod] + public void WithoutConverter_DifferentSourceOptions_AreCachedSeparately() + { + JsonSerializerOptions otherSource = new JsonSerializerOptions(); + otherSource.Converters.Add(new LOConverter()); + + JsonSerializerOptions fromDefault = JsonSerializerOptionsCache.WithoutConverter(XrplJsonOptions.Default); + JsonSerializerOptions fromOther = JsonSerializerOptionsCache.WithoutConverter(otherSource); + + Assert.AreNotSame(fromDefault, fromOther); + Assert.AreSame(fromOther, JsonSerializerOptionsCache.WithoutConverter(otherSource)); + } + + /// + /// The regression this cache needs guarded is a converter quietly going back to building its own copy + /// per call. Options identity cannot detect that — System.Text.Json hands converters the options of the + /// pooled caching context, so a per-call copy still shows up as one shared instance. What does detect it + /// is the cache entry itself: the run below uses a private options instance no other test touches, so an + /// entry for it can only have been created by a converter asking the cache during this deserialization. + /// + [TestMethod] + public void Deserialize_GoesThroughTheCache() + { + JsonSerializerOptions privateOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + }; + privateOptions.Converters.Add(new LOConverter()); + privateOptions.Converters.Add(new LedgerEntryTypeConverter()); + + Assert.IsFalse( + JsonSerializerOptionsCache.HasCachedEntry(privateOptions), + "Fresh options must start with no cache entry"); + + string json = @"{""account"":""rTest"",""account_objects"":[ + {""LedgerEntryType"":""Offer"",""Account"":""rTest"",""Sequence"":1,""index"":""AA""}, + {""LedgerEntryType"":""Offer"",""Account"":""rTest"",""Sequence"":2,""index"":""BB""}]}"; + + AccountObjects response = JsonSerializer.Deserialize(json, privateOptions); + + Assert.HasCount(2, response.AccountObjectList); + Assert.IsInstanceOfType(response.AccountObjectList[0], typeof(LOOffer)); + Assert.IsTrue( + JsonSerializerOptionsCache.HasCachedEntry(privateOptions), + "LOConverter must resolve its inner options through the cache, not build a copy per element"); + } +} diff --git a/Xrpl/Client/Json/Converters/GenericStringConverter.cs b/Xrpl/Client/Json/Converters/GenericStringConverter.cs index 80d8ba33..9d105eaa 100644 --- a/Xrpl/Client/Json/Converters/GenericStringConverter.cs +++ b/Xrpl/Client/Json/Converters/GenericStringConverter.cs @@ -15,12 +15,7 @@ public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerial { if (reader.TokenType == JsonTokenType.StartObject) { - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - for (int i = innerOptions.Converters.Count - 1; i >= 0; i--) - { - if (innerOptions.Converters[i] is GenericStringConverter) - innerOptions.Converters.RemoveAt(i); - } + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter>(options); return JsonSerializer.Deserialize(ref reader, innerOptions); } diff --git a/Xrpl/Client/Json/Converters/LONFTokenConverter.cs b/Xrpl/Client/Json/Converters/LONFTokenConverter.cs index 84699e10..e19f1b37 100644 --- a/Xrpl/Client/Json/Converters/LONFTokenConverter.cs +++ b/Xrpl/Client/Json/Converters/LONFTokenConverter.cs @@ -28,8 +28,7 @@ public override void Write(Utf8JsonWriter writer, NFToken value, JsonSerializerO writer.WritePropertyName("NFToken"); // Remove this converter to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - innerOptions.Converters.Remove(this); + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); JsonSerializer.Serialize(writer, value, innerOptions); writer.WriteEndObject(); diff --git a/Xrpl/Client/Json/Converters/LedgerBinaryConverter.cs b/Xrpl/Client/Json/Converters/LedgerBinaryConverter.cs index 3e9dc0d5..3036c9e7 100644 --- a/Xrpl/Client/Json/Converters/LedgerBinaryConverter.cs +++ b/Xrpl/Client/Json/Converters/LedgerBinaryConverter.cs @@ -26,8 +26,7 @@ public override void Write(Utf8JsonWriter writer, IBaseLedgerEntity value, JsonS } // Serialize the concrete type to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - innerOptions.Converters.Remove(this); + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); if (value is LedgerBinaryEntity binaryEntity) JsonSerializer.Serialize(writer, binaryEntity, innerOptions); @@ -59,8 +58,7 @@ public override IBaseLedgerEntity Read(ref Utf8JsonReader reader, Type typeToCon string rawJson = root.GetRawText(); // Remove this converter to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - innerOptions.Converters.Remove(this); + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); Type targetType = DetermineType(root); return (IBaseLedgerEntity)JsonSerializer.Deserialize(rawJson, targetType, innerOptions); diff --git a/Xrpl/Client/Json/Converters/LedgerObjectConverter.cs b/Xrpl/Client/Json/Converters/LedgerObjectConverter.cs index 6cf3e59c..cd870d1c 100644 --- a/Xrpl/Client/Json/Converters/LedgerObjectConverter.cs +++ b/Xrpl/Client/Json/Converters/LedgerObjectConverter.cs @@ -229,12 +229,7 @@ public static BaseLedgerEntry GetBaseRippleLO( string rawJson = element.Value.GetRawText(); // Remove LOConverter to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - for (int i = innerOptions.Converters.Count - 1; i >= 0; i--) - { - if (innerOptions.Converters[i] is LOConverter) - innerOptions.Converters.RemoveAt(i); - } + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); Type targetType = GetTypeForLedgerEntry(type); @@ -270,12 +265,7 @@ public override void Write(Utf8JsonWriter writer, BaseLedgerEntry value, JsonSer } // Serialize the concrete runtime type to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - for (int i = innerOptions.Converters.Count - 1; i >= 0; i--) - { - if (innerOptions.Converters[i] is LOConverter) - innerOptions.Converters.RemoveAt(i); - } + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); JsonSerializer.Serialize(writer, value, value.GetType(), innerOptions); } @@ -329,9 +319,12 @@ private static Type DetermineType(Type objectType, JsonElement root) ? letEl.GetString() : null; + // TryParse writes default(LedgerEntryType) — which is AccountRoot — on failure, so an + // unrecognized type must be mapped back to Unknown explicitly instead of being read as an + // account root with every field silently dropped. LedgerEntryType entryType = LedgerEntryType.Unknown; - if (ledgerEntryType != null) - Enum.TryParse(ledgerEntryType, ignoreCase: true, out entryType); + if (ledgerEntryType != null && !Enum.TryParse(ledgerEntryType, ignoreCase: true, out entryType)) + entryType = LedgerEntryType.Unknown; return GetTypeForLedgerEntry(entryType); } @@ -346,12 +339,7 @@ public override BaseLedgerEntry Read(ref Utf8JsonReader reader, Type typeToConve string rawJson = root.GetRawText(); // Remove LOConverter to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - for (int i = innerOptions.Converters.Count - 1; i >= 0; i--) - { - if (innerOptions.Converters[i] is LOConverter) - innerOptions.Converters.RemoveAt(i); - } + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); return (BaseLedgerEntry)JsonSerializer.Deserialize(rawJson, targetType, innerOptions); } diff --git a/Xrpl/Client/Json/Converters/MetaBinaryConverter.cs b/Xrpl/Client/Json/Converters/MetaBinaryConverter.cs index 0880d5da..e0d8c719 100644 --- a/Xrpl/Client/Json/Converters/MetaBinaryConverter.cs +++ b/Xrpl/Client/Json/Converters/MetaBinaryConverter.cs @@ -23,8 +23,7 @@ public override void Write(Utf8JsonWriter writer, Meta value, JsonSerializerOpti } // Remove this converter from options to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - innerOptions.Converters.Remove(this); + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); JsonSerializer.Serialize(writer, value, innerOptions); } @@ -46,8 +45,7 @@ public override Meta Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSer if (reader.TokenType == JsonTokenType.StartObject) { // Remove this converter from options to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - innerOptions.Converters.Remove(this); + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); return JsonSerializer.Deserialize(ref reader, innerOptions); } diff --git a/Xrpl/Client/Json/Converters/TransactionRequestConverter.cs b/Xrpl/Client/Json/Converters/TransactionRequestConverter.cs index f8403893..2c1310c6 100644 --- a/Xrpl/Client/Json/Converters/TransactionRequestConverter.cs +++ b/Xrpl/Client/Json/Converters/TransactionRequestConverter.cs @@ -20,8 +20,7 @@ public override void Write(Utf8JsonWriter writer, ITransactionRequest value, Jso } // Remove this converter to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - innerOptions.Converters.Remove(this); + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); JsonSerializer.Serialize(writer, value, value.GetType(), innerOptions); } @@ -157,12 +156,7 @@ public override ITransactionRequest Read(ref Utf8JsonReader reader, Type typeToC string rawJson = root.GetRawText(); // Remove this converter to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - for (int i = innerOptions.Converters.Count - 1; i >= 0; i--) - { - if (innerOptions.Converters[i] is TransactionRequestConverter) - innerOptions.Converters.RemoveAt(i); - } + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); try { diff --git a/Xrpl/Client/Json/Converters/TransactionResponseConverter.cs b/Xrpl/Client/Json/Converters/TransactionResponseConverter.cs index a87081b3..d9fe9593 100644 --- a/Xrpl/Client/Json/Converters/TransactionResponseConverter.cs +++ b/Xrpl/Client/Json/Converters/TransactionResponseConverter.cs @@ -23,12 +23,7 @@ public override void Write(Utf8JsonWriter writer, ITransactionResponse value, Js } // Remove this converter to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - for (int i = innerOptions.Converters.Count - 1; i >= 0; i--) - { - if (innerOptions.Converters[i] is TransactionResponseConverter) - innerOptions.Converters.RemoveAt(i); - } + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); JsonSerializer.Serialize(writer, value, value.GetType(), innerOptions); } @@ -169,12 +164,7 @@ public override ITransactionResponse Read(ref Utf8JsonReader reader, Type typeTo string rawJson = root.GetRawText(); // Remove this converter to avoid infinite recursion - JsonSerializerOptions innerOptions = new JsonSerializerOptions(options); - for (int i = innerOptions.Converters.Count - 1; i >= 0; i--) - { - if (innerOptions.Converters[i] is TransactionResponseConverter) - innerOptions.Converters.RemoveAt(i); - } + JsonSerializerOptions innerOptions = JsonSerializerOptionsCache.WithoutConverter(options); try { diff --git a/Xrpl/Client/Json/JsonSerializerOptionsCache.cs b/Xrpl/Client/Json/JsonSerializerOptionsCache.cs new file mode 100644 index 00000000..3128ce50 --- /dev/null +++ b/Xrpl/Client/Json/JsonSerializerOptionsCache.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Xrpl.Client.Json +{ + /// + /// Caches the derived that polymorphic converters build when they + /// re-enter the serializer with their own converter removed to avoid infinite recursion. + /// + /// + /// Building those options inside Read/Write cost an allocation, a copy of the whole converter list and a + /// structural-equality lookup in System.Text.Json's caching-context pool — once per converted value, so + /// once per element of a collection. Type metadata itself was not rebuilt: since .NET 8 System.Text.Json + /// shares a caching context between structurally equal options instances, which is what kept the per-call + /// copy from being far worse than it was. That pool is capped (64 contexts); caching here removes the + /// dependency on it as well.
+ /// Measured on 200 account_objects pages of 200 entries: 456 ms / 47 MB allocated before, + /// 217 ms / 29 MB after.
+ /// Entries are keyed weakly by the source options instance, so caller-supplied options stay collectable, + /// and by converter type within that instance.
+ /// Caching the source is safe because System.Text.Json freezes an options instance on first use: by the + /// time a converter runs, the options it was handed can no longer change. + ///
+ internal static class JsonSerializerOptionsCache + { + private static readonly ConditionalWeakTable> Cache = new(); + + /// + /// Returns a copy of with every converter of type + /// removed. Repeated calls with the same source options and the + /// same converter type return the same instance. + /// + /// Converter type to strip from the returned options. + /// Source options, as handed to the converter. + public static JsonSerializerOptions WithoutConverter(JsonSerializerOptions options) + where TConverter : JsonConverter + { + ConcurrentDictionary byConverterType = + Cache.GetValue(options, static _ => new ConcurrentDictionary()); + + return byConverterType.GetOrAdd(typeof(TConverter), static (_, source) => Build(source), options); + } + + /// + /// Whether already has a cached entry for . + /// Exists so tests can assert that a converter went through the cache rather than building its own + /// copy — the two are indistinguishable from the outside, because System.Text.Json hands converters + /// the options of the pooled caching context rather than the instance they were called with. + /// + internal static bool HasCachedEntry(JsonSerializerOptions options) + where TConverter : JsonConverter + { + return Cache.TryGetValue(options, out ConcurrentDictionary byConverterType) + && byConverterType.ContainsKey(typeof(TConverter)); + } + + private static JsonSerializerOptions Build(JsonSerializerOptions source) + where TConverter : JsonConverter + { + JsonSerializerOptions derived = new JsonSerializerOptions(source); + for (int i = derived.Converters.Count - 1; i >= 0; i--) + { + if (derived.Converters[i] is TConverter) + derived.Converters.RemoveAt(i); + } + + return derived; + } + } +} diff --git a/Xrpl/Models/Methods/AccountObjects.cs b/Xrpl/Models/Methods/AccountObjects.cs index 385b9881..be5b0ec8 100644 --- a/Xrpl/Models/Methods/AccountObjects.cs +++ b/Xrpl/Models/Methods/AccountObjects.cs @@ -20,10 +20,14 @@ public class AccountObjects //todo rename to response public string Account { get; set; } /// /// Array of objects owned by this account.
- /// Each object is in its raw ledger format. + /// Each object is in its raw ledger format.
+ /// Elements are deserialized into the concrete LO* type named by their LedgerEntryType + /// discriminator by , which is registered globally in ; + /// a type the SDK does not know falls back to itself, which is why that + /// one stays a concrete class rather than an interface. ///
[JsonPropertyName("account_objects")] - public List AccountObjectList { get; set; } //todo change from class to interface and parse same as transactionResponse + public List AccountObjectList { get; set; } /// /// The identifying hash of the ledger that was used to generate this response. /// diff --git a/Xrpl/Xrpl.csproj b/Xrpl/Xrpl.csproj index 07e16dea..8adc651e 100644 --- a/Xrpl/Xrpl.csproj +++ b/Xrpl/Xrpl.csproj @@ -21,6 +21,9 @@ 4 true + + + From d3c8f50ce953aec207535118c04f7adabbba7070 Mon Sep 17 00:00:00 2001 From: Aleksandr <44946855+Platonenkov@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:49:45 +0000 Subject: [PATCH 14/14] =?UTF-8?q?fix(sugar):=20=D0=BD=D0=B5=20=D0=B3=D0=BB?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=D1=82=D1=8C=20=D0=BE=D1=82=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=83=20=D0=B2=20fallback-=D0=B0=D1=85=20autofill=20+=20Valida?= =?UTF-8?q?tionException=20=D0=B4=D0=BB=D1=8F=20Flags=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Замечания CodeRabbit к релизному PR #76. - FetchCounterpartySignerCount и FetchLoan оборачивают вызов клиента широким catch — это верно для случая, ради которого он существует (аккаунт контрагента или объект Loan ещё не созданы, дальше отработает preclaim), но туда же уходил OperationCanceledException от токена вызывающего. Autofill продолжал работу и записывал комиссию, посчитанную по fallback — один подписант, отсутствующий заём, — для запроса, который вызывающий уже отменил. Добавлен фильтр when (!cancellationToken.IsCancellationRequested): отмена вызывающего всплывает, а таймаут внутри клиента, который этот токен не отменяет, по-прежнему уходит в fallback - MPTokenIssuanceSet: Flags разбирался через Convert.ToUInt32, бросающий FormatException/InvalidCastException на нечисловом значении, тогда как соседняя проверка ImmutableFlags и остальные валидаторы сообщают ValidationException — её и ловят вызывающие Тесты: отменённый токен обязан бросить и не оставить Fee (LoanSet и LoanPay), нечитаемый объект Loan обязан по-прежнему уходить в fallback, нечисловой Flags — давать ValidationException. Первые два падают, если убрать фильтры. --- CHANGES.md | 2 + .../Models/TestUProtocolCompleteness.cs | 8 +- Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs | 73 ++++++++++++++++++- .../Models/Transactions/MPTokenIssuanceSet.cs | 9 ++- Xrpl/Sugar/Autofill.cs | 10 ++- 5 files changed, 97 insertions(+), 5 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 1fb655ad..43133aa8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -24,6 +24,8 @@ * `.ci-config/bump-nightly-pin.sh` does the move: newest `xrpld` build from the nightly apt channel, `ARG XRPLD_VERSION` rewritten, `rippled.batchv11.cfg` regenerated from the develop commit **encoded in that version string** — config and binary cannot drift apart, which is the failure mode the old manual two-step invited. `--check` reports the pin, the newest build and the pin's age without touching anything. Both timestamp formats are compared by their common `YYYYMMDDHHMM` prefix * the workflow bumps only once the pin is older than `MAX_PIN_AGE_DAYS` (21) — nightly publishes several builds a day, and a weekly PR would be noise rather than signal; `workflow_dispatch` takes a `force` input for the exceptions. It then builds and starts the stand on the new pin and requires the AMM sentinel amendment to come up enabled at genesis, which is what proves the regenerated config was accepted rather than silently ignored, and attaches the definitions diff against the new build to the PR body — a `node-only` field there is the SDK being behind develop, reported instead of hidden * credentials, idempotency and the tracking-issue fallback follow release-watch exactly, including the one-notification-per-failure-streak rule +* **Cancellation no longer disappears into the autofill fee fallbacks** — `FetchCounterpartySignerCount` and `FetchLoan` wrap their client call in a broad `catch`, which is right for the case they exist for (the counterparty account or the Loan object is not there yet, and preclaim will report it) but also swallowed an `OperationCanceledException` raised from the caller's own token. Autofill then carried on and wrote a fee derived from the fallback — one signer, no loan — for a request the caller had already abandoned. Both catches now carry `when (!cancellationToken.IsCancellationRequested)`, which lets a caller's cancellation through while a client-side timeout, which does not cancel that token, still falls back as before. Covered in both directions: a cancelled token must throw and leave no `Fee` behind, an unreadable Loan object must still fall back +* **`MPTokenIssuanceSet` validation reports a malformed `Flags` as `ValidationException`** — it went through `Convert.ToUInt32`, which throws `FormatException` or `InvalidCastException` on a non-numeric value, while the `ImmutableFlags` check two lines below reports `ValidationException` like the rest of the validators. Callers catching `ValidationException` did not catch the other two * **The conformance fixtures are re-pinned to the 3.3.0 tag** — `transactions.macro` and `LedgerFormats.h` now come from the release commit (`00a178fb`) instead of a July `develop` sha and `3.3.0-rc1`; `ledger_entries.macro` stays on `develop` (`9859e5ce`) for the reason its `.ref` already gives — `sfLEVersion` exists only there. Both macro files are byte-identical to upstream and re-verifiable with the `curl … | diff` line in each `.ref`. This is what makes the guards test against the version CI actually runs: * `RippledLedgerFlags.Parse` learned to read the `lsif*` values. In 3.3.0 they are no longer a `LEDGER_OBJECT(MPTokenIssuanceMutable, …)` block but plain `inline constexpr std::uint32_t` constants next to the macro list, so the flag guard would have quietly lost that enum entirely. They are reported under a synthetic `MPTokenIssuanceImmutable` object, and a parse that finds none of them now throws instead of returning a thinner table * **Why the weekly Definitions Watch stayed green through all of this** — `definitions-watch.yml` raises a stand from `docker-compose.batchv11.yml`, i.e. the **pinned** nightly `XRPLD_VERSION`. While the pin is stale the monitor diffs `definitions.json` against a build older than the one CI runs, and reports "in sync" about the past. The pin needs to move with every stable bump, not only when a new amendment is wanted diff --git a/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs b/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs index 2195911f..5d62982c 100644 --- a/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs +++ b/Tests/Xrpl.Tests/Models/TestUProtocolCompleteness.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; @@ -205,6 +205,12 @@ public async Task TestUMPTokenIssuanceSet_PreflightRules() ["MPTokenIssuanceID"] = "00000001A407AF5856CCF3C42619DAA925813FC955C72983", }; + // A non-numeric Flags value must report as ValidationException like every other + // malformed field here, not as a raw conversion exception callers do not catch. + tx["Flags"] = "not-a-number"; + await Assert.ThrowsExactlyAsync(() => Validation.ValidateMPTokenIssuanceSet(tx)); + tx.Remove("Flags"); + // ImmutableFlags: zero and out-of-mask values are temINVALID_FLAG tx["ImmutableFlags"] = 0u; await Assert.ThrowsExactlyAsync(() => Validation.ValidateMPTokenIssuanceSet(tx)); diff --git a/Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs b/Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs index d6f9926f..88391e29 100644 --- a/Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs +++ b/Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs @@ -1,4 +1,4 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using System.Text.Json.Nodes; @@ -341,6 +341,73 @@ public async Task TestUCalculateFee_LoanSet_ExistingCounterpartySignature_Counts #endregion + #region Cancellation + + /// + /// A cancelled token must stop fee calculation rather than be absorbed by the fallback that + /// exists for a counterparty account which does not exist yet. + /// + /// + /// Both lookups sit behind a broad catch, so without an exception filter the + /// OperationCanceledException became a silent "assume one signer" and autofill carried on + /// writing a fee the caller never asked for. + /// + [TestMethod] + public async Task TestUCalculateFee_LoanSet_CancellationIsNotSwallowedByTheSignerFallback() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) + { + CounterpartySignerLists = CreateSignerList(3) + }; + var tx = CreateLoanSetTx(); + + using CancellationTokenSource cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAsync( + () => client.CalculateFeePerTransactionType(tx, 0, cts.Token)); + + Assert.IsFalse(tx.ContainsKey("Fee"), "A cancelled autofill must not leave a fee behind."); + } + + /// The same for the Loan lookup, whose fallback is a null object. + [TestMethod] + public async Task TestUCalculateFee_LoanPay_CancellationIsNotSwallowedByTheLoanFallback() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) + { + LoanEntry = CreateLoan(paymentRemaining: 50) + }; + var tx = CreateLoanPayTx(amount: "10000"); + + using CancellationTokenSource cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAsync( + () => client.CalculateFeePerTransactionType(tx, 0, cts.Token)); + + Assert.IsFalse(tx.ContainsKey("Fee"), "A cancelled autofill must not leave a fee behind."); + } + + /// + /// The filter must not turn every failure into a hard error: a lookup that fails on its own — + /// the object is missing — still falls back while the caller's token is untouched. + /// + [TestMethod] + public async Task TestUCalculateFee_LoanPay_FailedLookupStillFallsBackWhenNotCancelled() + { + var client = new FeeTestClient(MAINNET_BASE_FEE, RESERVE_INC) { LedgerEntryThrows = true }; + var tx = CreateLoanPayTx(amount: "10000"); + + using CancellationTokenSource cts = new CancellationTokenSource(); + + await client.CalculateFeePerTransactionType(tx, 0, cts.Token); + + Assert.IsTrue(tx.ContainsKey("Fee"), "An unreadable Loan object is a fallback, not a failure."); + } + + #endregion + #region LoanPay Fee Tests [TestMethod] @@ -650,6 +717,9 @@ public Task ServerState(ServerStateRequest request, CancellationTok public Task Fee(CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task AccountInfo(AccountInfoRequest request, CancellationToken cancellationToken = default) { + // Honour the token the way a real client does, so tests can assert that a caller's + // cancellation reaches autofill instead of being turned into a fee fallback. + cancellationToken.ThrowIfCancellationRequested(); AccountInfoCalls++; LastAccountInfoRequest = request; return Task.FromResult(new AccountInfo { SignerLists = CounterpartySignerLists }); @@ -669,6 +739,7 @@ public Task AccountInfo(AccountInfoRequest request, CancellationTok public Task LedgerData(LedgerDataRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task LedgerEntry(LedgerEntryRequest request, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); LedgerEntryCalls++; LastLedgerEntryRequest = request; if (LedgerEntryThrows) diff --git a/Xrpl/Models/Transactions/MPTokenIssuanceSet.cs b/Xrpl/Models/Transactions/MPTokenIssuanceSet.cs index 8e0e11e5..b1c75a50 100644 --- a/Xrpl/Models/Transactions/MPTokenIssuanceSet.cs +++ b/Xrpl/Models/Transactions/MPTokenIssuanceSet.cs @@ -244,7 +244,14 @@ public static async Task ValidateMPTokenIssuanceSet(Dictionary t uint flagValue = 0; if (tx.TryGetValue("Flags", out var flags) && flags is not null) { - flagValue = Convert.ToUInt32(flags); + // Same reporting as the ImmutableFlags check below: a non-numeric value has to + // surface as ValidationException, which is what callers of this method catch — + // Convert.ToUInt32 would throw FormatException or InvalidCastException instead. + if (!Common.TryGetUInt32(flags, out flagValue)) + { + throw new ValidationException("MPTokenIssuanceSet: Flags must be a number"); + } + bool hasLock = (flagValue & (uint)MPTokenIssuanceSetFlags.tfMPTLock) != 0; bool hasUnlock = (flagValue & (uint)MPTokenIssuanceSetFlags.tfMPTUnlock) != 0; diff --git a/Xrpl/Sugar/Autofill.cs b/Xrpl/Sugar/Autofill.cs index 33d879a3..92d42be1 100644 --- a/Xrpl/Sugar/Autofill.cs +++ b/Xrpl/Sugar/Autofill.cs @@ -343,9 +343,13 @@ private static async Task FetchCounterpartySignerCount(IXrplClient client, int? entries = data?.SignerLists?.Length > 0 ? data.SignerLists[0].SignerEntries?.Count : null; return entries is > 0 ? entries.Value : 1; } - catch (Exception) + catch (Exception) when (!cancellationToken.IsCancellationRequested) { // The counterparty account may not exist yet; preclaim rejects the transaction anyway. + // The filter keeps a caller's cancellation out of that fallback: without it an + // OperationCanceledException would be swallowed and autofill would carry on with a + // guessed signer count instead of stopping. A timeout inside the client still falls + // back, since it does not cancel this token. return 1; } } @@ -424,8 +428,10 @@ private static async Task FetchLoan(IXrplClient client, string loanId, C LedgerEntryResponse response = await client.LedgerEntry(request, cancellationToken); return response?.Node as LOLoan; } - catch (Exception) + catch (Exception) when (!cancellationToken.IsCancellationRequested) { + // Same reasoning as FetchCounterpartySignerCount: a missing object is a fallback, + // a cancellation asked for by the caller is not. return null; } }