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:
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/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 19dab341..e4603159 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,5 +1,69 @@
# Changes
+## 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
+ * 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)
+ * `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
+ * **`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
+
+* **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/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/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/TestUChangeServerFailure.cs b/Tests/Xrpl.Tests/Client/TestUChangeServerFailure.cs
new file mode 100644
index 00000000..916ea34c
--- /dev/null
+++ b/Tests/Xrpl.Tests/Client/TestUChangeServerFailure.cs
@@ -0,0 +1,190 @@
+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.
+ // 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);
+ 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.
+ }
+
+ 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);
+ 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..c8c372e5
--- /dev/null
+++ b/Tests/Xrpl.Tests/Client/TestUOnConnectedHandlerFailure.cs
@@ -0,0 +1,313 @@
+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");
+ }
+
+ ///
+ /// 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/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/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/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