Skip to content

Latest commit

 

History

History
1117 lines (941 loc) · 289 KB

File metadata and controls

1117 lines (941 loc) · 289 KB

Changes

11.6.0.0 21/09/2026

  • The protocol schema follows rippled develop at e3c8996e, the 3.4.0-rc1 build the nightly stand is pinned to (#182 bumps the pin). definitions.json had been synced for 3.3.0 and was eight fields behind develop, which definitions-watch had reported as node-only for three weeks. The nightly-pin bump that would have listed them opened with an empty "definitions.json vs the new build" section: the step inherits bash -e from the runner, and the diff exits 1 whenever it finds drift, so errexit ended the step at the assignment - before the report was echoed or recorded - in the one case the step exists for. Fixed alongside.

    • closed-ended vaults (rippled #7921, LendingProtocolV1_1): VaultCreate and LOVault carry VaultKind, SubscriptionDate and RedemptionDate, and the VaultKind enum names the two kinds. ValidateVaultCreate pins rippled's preflight: the dates only on a closed-ended vault, both of them, with the redemption at least three minutes and less than thirty years after the subscription (rippled #8151 raised the floor from one minute; caught by running the closed-ended flow on the nightly stand). Deposits are accepted in the subscription phase only, withdrawals in every phase but investment
    • confidential MPT key rotation (rippled #7915, ConfidentialMPTKeyRotation): LOMPTokenIssuance carries IssuerKeyEpoch and AuditorKeyEpoch, incremented each time MPTokenIssuanceSet replaces the key. The transaction is unchanged - the same IssuerEncryptionKey/AuditorEncryptionKey fields rotate a key once the amendment is active, and the current key is refused with tecDUPLICATE. LOMPToken carries the holder side, IssuerKeyMirrorEpoch and AuditorKeyMirrorEpoch: the epoch each mirrored encrypted balance was produced under, which rippled compares against the issuance's epoch to decide whether the mirror is still current, so a rotation marks it stale. ContractResult (rippled #7988) is known to the codec but belongs to no format yet
    • the vendored ledger_entries.macro moves to develop f6b51f0b, the commit that placed the mirror epochs on MPToken. TestULedgerEntryFieldsConformance reads that file in both directions, so moving the pin also named the two Smart Escrow entries it carries, and the models follow them: LOEscrow gains Bytecode and Data, LOFeeSettings the voted GasLimit, BytecodeSizeLimit and GasPrice. Ledger-object fields only - the SmartEscrow amendment is Supported::No on every build, so nothing returns them yet and the transaction side stays out of this release
    • VaultWithdraw and LoanBrokerCoverWithdraw accept CredentialIDs, for a Destination that requires deposit authorization; validated the way Payment.CredentialIDs is
    • a protocol field is a member on the transaction's interface as well as on its classes, so IVaultCreate, IVaultWithdraw and ILoanBrokerCoverWithdraw each gained one. Anything outside the SDK that implements one of those interfaces - an adapter, a test double - stops compiling until it declares the new member. No default bodies: on a data contract a default would have to accept a value and drop it, which is the silent outcome these interfaces exist to avoid, and unlike IXrplClient nothing implements them to add behaviour
    • the vendored transactions.macro is pinned to the same develop commit as ledger_entries.macro instead of the 3.3.0 tag, so both conformance tests describe the build the nightly stand runs. Up to 3.3.0 the tag and develop agreed on transaction fields; they no longer do
    • Xrpl.BinaryCodec 11.6.0.0 for the new codec entries, numbered with Xrpl since both move in this release. The CI stand (3.3.0) knows none of the new fields, so the unit suite covers them with round trips and validation, and TestIClosedEndedVault drives them against the nightly stand (LendingProtocolV1_1 at genesis, the pin from #182): a closed-ended vault through its three phases, an open-ended one carrying no VaultKind on the ledger, and a VaultWithdraw to a deposit-authorized destination that is tecNO_PERMISSION without CredentialIDs and succeeds with them. Key rotation has no stand: ConfidentialMPTKeyRotation is Supported::No, and the shared amendment generator cannot preset a name the 3.3.0 binary does not know
    • the Loan integration suite follows the amendment too. Under LendingProtocolV1_1 LoanBrokerSet refuses an open-ended vault ("LoanBroker requires a closed-ended Vault", tecNO_PERMISSION) and every Loan test built its broker on one, so all 18 of TestILoan failed on the nightly stand - identically on untouched dev, which is what said it was the node's rule rather than a regression. TestILoanBase now creates a closed-ended vault where the node asks for one and waits for the investment phase before handing the broker back: rippled originates a loan only there, while the vault deposit that funds it is taken only in the subscription phase before it, and the two dates are measured from the ledger's close time rather than the machine's clock, which on a standalone stand is a different clock. A node without the amendment does not know the fields at all - it answers invalidTransaction, not a result code - so the open-ended path stays for it, chosen through AmendmentGuard
    • what that unblocks, and what it does not: on the nightly stand TestILoan goes from 0 of 18 to 7 of 18, and all 11 that still failed reported Counterparty: Invalid signature - the role signing prefixes fixCleanup3_4_0 introduces, which is what the entry above this one goes on to implement, and which was the whole of what remained. TestISponsoredVaultLoan is blocked by the same thing on 3.4.x, at Sponsor: Invalid signature. Neither failure was about vaults any more, which is what this entry set out to establish; the signatures are the subject of the entry above, and are green there. The CI stand (3.3.0) is untouched by any of it; what the suite looks like there once the signing change lands is counted in the entry above
    • ValidatedCloseTimeAsync and WaitForCloseTimeAsync moved to IntegrationTestConfig: three test classes now need the ledger clock, and each had been carrying its own copy
  • A sponsor's and a counterparty's signature cover bytes of their own (rippled fixCleanup3_4_0, breaking against nodes without the amendment). Until it, every signature on a transaction covered the same bytes: the submitter's TxnSignature, the sponsor's SponsorSignature (XLS-68) and the borrower's CounterpartySignature (XLS-66) were all made over one preimage, so a signature could be lifted out of one role and pasted into another and still verify. rippled now gives each role its own four-byte hash prefix, and this release signs that way.

    • HashPrefix gains CounterpartyTransactionSig, CounterpartyTransactionMultiSig, SponsorTransactionSig and SponsorTransactionMultiSig, and EncodeForSigning / EncodeForMultiSigning gain overloads taking one. Overloads rather than an optional parameter, for the binary compatibility reason the SDK has met before. The transaction's own prefixes are untouched, so an ordinary signature - single or multisig, Batch included - is byte-for-byte what it was
    • the role is not something a caller states. It follows from the method: a wallet named as the Sponsor signs as sponsor, a LoanSet Counterparty as counterparty, and a multi-signature entry on a transaction whose main signature is single can only belong to the co-signing side. One shape is genuinely ambiguous, where the main signature and a co-signature are both multi-signed, and only there does the signer have to say: Sign(tx, multisign, signingFor, SignatureRole.Sponsor). Asking for it in a shape that does not have it is refused rather than signed wrongly
    • multi-signature entries are no longer portable between sections. The composer's premise - that tx.Signers, SponsorSignature.Signers and CounterpartySignature.Signers are identical bytes, so routing could be settled at composition time - held only before the amendment. Routing still happens in the composer, by account, but the signer now has to know its side already
    • what breaks: a signature this release makes is rejected by a node that has Sponsor or LendingProtocol enabled but not fixCleanup3_4_0. No public network is in that state - on mainnet and testnet none of the three is enabled, on devnet all of them are - so the affected combination is a private node on a release older than the amendment. The older scheme is not carried, and nothing asks the node which one it wants: signing stays offline
    • the one place the combination does occur is this repository's own CI stand, a 3.3.0 image with Sponsor and LendingProtocol voted in at genesis, so the integration tests that produce a role signature now skip there through AmendmentGuard and run on the nightly stand instead. Sixty of them, measured: the CI stand ran 346 integration tests before this release and runs 289, with 60 skipped. The guard sits on the tests that actually make a role signature, not on their classes - the SponsorshipSet tests carry none and keep running there. It is coverage deferred rather than lost, on a stand rather than in the suite, and the guard turns it back on by itself once the CI stand moves to a release carrying the amendment
    • the composer now checks what it composes. Routing an entry by account is a guess about what its signer meant, and since the amendment a wrong guess is no longer harmless. ComposeSignatures verifies every signature in the finished transaction against the bytes that transaction ships, under the prefix of the section the signature landed in, and names the account and the section when one does not verify. It catches the shape the transaction cannot express - the main signature multi-signed as well, so an entry could belong to either side - and the other direction too, a part signed over a SigningPubKey the composed transaction does not carry. Both were found by a cold review of this branch, on two models independently, and both used to reach the node as a transaction the caller believed was signed
    • SponsorSigningHelper.GetSigningPreimage is now GetSponsorPreimage, and the internal loan one GetCounterpartyPreimage. The name returned the bytes of both signatures while they were the same; keeping it would have changed what a call means without changing how it compiles. No [Obsolete] bridge, the same policy the breaks above it follow: a member that refuses is still a member on the public surface, and the compile error a removal gives is the migration notice
    • pinned by unit tests that read the prefixes out of rippled's own HashPrefix.h, vendored as a fixture beside the other protocol files, and assert that a role preimage is the transaction's preimage with four bytes changed and nothing else - a pinned blob cannot answer that question, since regenerating it from the same code only agrees with itself. What settles it is the node: on the nightly stand TestILoan passes 18 of 18 and the sponsorship classes are green, where before this release every one of them was refused with Invalid signature
    • two rules of the same amendment surfaced once the tests could reach them, and are in the tests rather than in the SDK: a loan may only be impaired once a payment is actually late, and a payment on an overdue loan must carry tfLoanLatePayment or it is tecEXPIRED

11.5.1.0 15/09/2026

  • Transaction validation is synchronous, and BatchUtils.Build validates what it assembles (breaking). Validation.Validate and all 86 per-transaction validators behind it were declared async Task without ever awaiting anything - every one of them is straight-line field checking. Nothing in the SDK is measurably faster for it, but one caller paid for the disguise: BatchUtils.Build called Validation.Validate(...) and discarded the task. An async method captures every exception into the task it returns, including the ones thrown before the first await, so a discarded task is a discarded verdict: ValidateBatch ran, decided the batch was malformed, and reported it to nobody. A Batch built around a single inner transaction - which rippled answers with temARRAY_EMPTY - came back from Build looking well formed, and so did one with more than eight inners, with a Vault/Loan inner, with an inner missing tfInnerBatchTxn, or with an inner carrying a non-zero Fee. The compiler had been saying so since the method was written (CS4014).
    • the validators now return void and throw on the calling thread. await Validation.Validate(tx) no longer compiles: drop the await. This is the whole migration - the exception type, the message and the conditions are unchanged, and a try/catch around the call keeps working as it is.
    • this is a breaking change released as a patch, deliberately. Semver would call it a major: the return type is part of a method's signature in IL, so an assembly built against 11.5.0.0 meets a MissingMethodException on 11.5.1.0 even where it never wrote await, and its sources need the await dropped before they compile again. It is numbered a patch because the validators are opt-in - nothing inside the SDK calls them, and BatchUtils.Build, the one caller that did, is the method this release fixes. If you call Validation.* or Common.ValidateBaseTransaction directly, treat this upgrade as a major one: rebuild, and drop the await.
    • making the signature honest is what fixes the defect, rather than adding the missing await: Build is synchronous and public, so awaiting would have meant Task<Batch> BuildAsync, and .GetAwaiter().GetResult() would have left the next caller the same trap. A validator that cannot be forgotten is a validator that has no task to forget.
    • TestUCredentialsValidator wrapped the already-synchronous CredentialsValidator.ValidateCredentialsList in Task.Run purely to fit the async assertion helper's Func<Task>. The wrapper worked, because the helper awaited the task - but it is exactly the shape that stops working the moment the assertion becomes synchronous, since Action accepts a lambda whose value is discarded. The nine tests call the validator directly now.

11.5.0.0 13/09/2026

  • What happened to the connection is readable from the type, instead of the message text (the follow-up to #179). 11.4.0 made the behaviour correct - one owner per transition, an operation that was overtaken says so - but gave the caller no way to read that answer. NotConnectedException carried five different events and OperationCanceledException two, so the only way to tell "the consumer disconnected the client" from "this endpoint is not answering" was to classify by message text - which the release notes of 11.3.2.0 told consumers not to do, while the library gave them no type capable of it.
    • six new exception types, all deriving from the ones thrown today, so no catch clause changes meaning and no task changes status: ClientDisconnectedException and ReconnectExhaustedException (attempts spent, budget configured), RequestRefusedException for a request the caller asked not to have wait, ConnectHandlerFailedException (how many times the handler failed, and the handler's own exception), ConnectionClosedPermanentlyException for a node that closed with a code this client does not retry after, and NotConnectingException for a client with no attempt in progress. ConnectionSupersededException derives from OperationCanceledException and names the transition that took over and where it left the client
    • a broken OnConnected handler is no longer reported as a disconnect the consumer performed. The give-up path ends by calling Disconnect() itself, so every point reading the permanently-disconnected flag answered "the client has been disconnected" - for a client that is down because its own handler is broken, where the node is answering and failing over would leave a healthy server. The cause now travels with the disconnect, written in the same critical section as the flag it qualifies. The two existing tests on that path made the defect plain: the same broken handler produced one type when it failed immediately and another when it failed a moment later
    • a request swept while the connection moved says which transition swept it, and where the client went. This is the failure consumers meet most often, and it arrived as a bare OperationCanceledException reading "Connection was intentionally closed", indistinguishable from a cancellation of their own. Sweeps caused by the connection failing on its own - a network drop, a close being processed - deliberately keep the plain cancellation: that choice is what keeps an ordinary network drop out of consumers' critical logs, and the reason now reaches them on the status stream instead
    • ConnectionStatusInfo.StopReason says why the client stopped, and only when it stopped: the first handshake failure against a server that is not up is announced and then retried, so it names no reason - whether one is named is decided by the same condition that decides whether the retry happens. A consumer reading any reason as terminal would otherwise fail over to another server while this one was still being dialled.
    • ConnectionStatusInfo.StopReason says why the client stopped. Disconnected is announced from ten places and they differed only in text, so "still trying" against "gave up" was derivable only from the absence of ReconnectInfo - which is also what a client that never had a loop looks like. The reason goes on the notification rather than into ReconnectInfo, so Reconnect != null keeps its one meaning
    • WaitForConnectionOutcomeAsync answers "did it come back?" with a value rather than an exception, on Connection, on XrplClient and on IXrplClient - where the wait was previously unreachable except through the connection object. ConnectionWaitOutcome names the case rather than folding "timed out", "gave up" and "nothing is running" into one false. HasConnectionAsync is untouched: adding a CancellationToken overload beside it would make argument-less calls ambiguous at the call site (CS0121)
    • the wait no longer polls. It slept 100 ms at a time, which is slower than the event it waits for and, on a single-threaded host such as Blazor WebAssembly, more expensive than it looks - browser timers are throttled in a hidden tab, so a wait the event would satisfy at once stretched into seconds. It now sleeps on a signal completed by the one funnel every connection state passes through. Every check it made per pass is unchanged, so the answers are identical; the unit suite runs 43 s to 30 s
    • ChangeServer reads the network id the way Connect does. Connect has carried that read across a teardown since 11.4.0, because a socket really does open for a moment before a failing handler brings it down; ChangeServer read it once, directly, so a connection that needed a second attempt failed the switch
    • ConnectionManager is fixed rather than left alone. The readiness signal above is deliberately not built on it - it releases waiters when a connection is retired, and a retirement has to carry a waiting request over to the new connection rather than fail it - but it is public, reachable as client.connection.connectionManager, and notified from nine places on the connection's own threads. Nothing inside the SDK awaits it, so its defects had never shown: a waiter resumed inside ResolveAllAwaiting, which is called from inside OnceOpen before the OnConnected handler, and a registration landing during a notification either threw "Collection was modified" or was dropped and never resumed. The list is guarded, waiters are released outside the lock and resume asynchronously, completions are TrySet*, and a cancellation is TrySetCanceled rather than a faulted task
    • StopAfterMaxAttempts now actually stops the client. Found while writing a test for one of the paths above, and present since before this change: a client that spent its reconnect budget announced Disconnected and then ran a second full series from attempt #1, announcing it again. The loop's exit clears the two fields that say a sequence is running for this generation, which is exactly what "none is running" looks like, so the close of the attempt that failed last was indistinguishable from the close that began the whole thing - and, with the cancellation source already released, started a fresh sequence with the counter at zero. The generation that gave up is now recorded and refused a new loop. Keyed by generation rather than flagged, so nothing has to reset it: generations only increase, and a Connect() or ChangeServer begins a new one - which is when asking again is the consumer's decision. A client that stopped on its own still answers the consumer asking, and that is asserted alongside
    • the wait and the status stream cannot disagree about how the connection ended. Three ways they could, all found by the cold review of this change and all in the new code. The wait asked "is anything in progress?" before it asked "did something end?", and after a sequence ends there is no socket, no cancellation source and a Disconnected state - which is also exactly what a client nobody has called Connect() on looks like: a consumer who heard ReconnectExhausted on the status stream and confirmed it on the wait before failing over was told NotConnecting, and the outcome this whole change exists to deliver was reachable only by a caller who happened to be parked already. A close code the client does not reconnect after - 1002, 1003, 1007, 1010 - was announced as ClosedPermanently and left the wait nothing to recognise, so a parked caller was woken, found nothing, parked again, and was told a whole acquisition timeout later that the connection "was not established in time"; ConnectionWaitOutcome.ClosedPermanently and ConnectionClosedPermanentlyException are its counterpart. And the exhaustion the loop records, the permanent close, and the generation all used 0 for "none", which is the generation of a brand-new client - so the fields answered their own question with "yes" until the entry check above happened to mask it
    • one ownership check instead of three and a missing one. A status announcement that speaks for a single transition of the connection is only true while that transition still owns it, and the check sat at the call sites: three had it, one did not, and the missing one was invisible for as long as the deduplication happened to swallow what it let through. Making the stop reason a change in its own right stopped it swallowing. The check now lives inside the one funnel every announcement passes through, in the same critical section that publishes the state - which also closes the window where a caller read "the sequence is still running", lost the processor, and published a RestoringConnection after the loop had ended the sequence and announced the ending
    • a wait whose timeout is an exact multiple of the system clock tick - 15.625 ms, so one second and the default five minutes both are - spun on the connection's lock for the rest of the tick instead of timing out, because the deadline comparison was strict while the remaining time was already zero
    • every reason the client can stop for now has one outcome, under the same name, and one record behind both. Each ending used to leave its own residue for a caller to recognise - a flag for the consumer's own disconnect, a generation for a spent reconnect budget - so an ending that left none was invisible to everything except the status stream, and a caller parked in the wait sat out its whole timeout to be told the connection "was not established in time" about a connection it had already been told was over. Two such endings were found, in three places. ConnectionStopReason is now recorded where the announcement is published, by the one method every status passes through, and the wait, its outcome value and the check every request makes all read that one record - so the three ways of asking cannot come to know different things. A request issued after the endpoint gave up used to answer "no connection attempt in progress. Call Connect() first", the one distinction the exception family exists to draw. The correspondence is asserted over the enums themselves rather than a list, which is how the last mismatch was found: ConnectionStopReason.UserDisconnected had been paired with a ConnectionWaitOutcome.Disconnected, and the outcome is renamed to match
    • every value of both enums is asserted by a test, and one value was deleted for failing to be. Coverage was measured rather than assumed, and three holes came out of it: two endings whose exception was pinned but whose outcome value was not, and ConnectionStopReason.InitialConnectionFailed, which no test named because nothing can produce it. It is unreachable by construction - the branch that would name it is reached only for a socket that never opened as a session, and such a socket is always retried, which is the same fact willReconnect reads - and that was measured, not argued: the branch was instrumented and the whole suite run twice, once on the reason and once on willReconnect itself, for zero hits in 1328 tests. A value no consumer can observe invites a branch that never runs, so it is gone before release rather than kept as a defensive one; adding an enum member later is not a breaking change, and removing one is. The ending a consumer gets for a connection that never came up and stopped being retried is ReconnectExhausted, from the loop that stopped retrying it
    • a runnable sample of the whole thing, Tests/TestsClients/ConnectionLifecycleSample. It needs no wallet and no funded account, because the subject is the connection: it produces each ending in turn - nothing in progress, an endpoint that is down, a request with no connection, a switch to a node that is down and the recovery from it, a consumer disconnect, a broken OnConnected handler, and an operation another one overtook - printing the status stream each one produced and then the type the caller got, with one line per ending saying what a consumer is supposed to do about it. dotnet run --project Tests/TestsClients/ConnectionLifecycleSample against the CI stand, or pass any node's URL
    • a client that has given up no longer calls itself connected. The give-up path announces the ending, then rejects the requests in flight - which is what resumes the caller inside Connect() - and only then disconnects, so between the second step and the third the socket is still installed and still open. The wait began with a fast IsConnected() check and preferred "connected" to any ending, so a consumer was told the connection was up one instant after being told why it was over. The fast path is gone, the endings are read whether or not a socket happens to be open, and a recorded ending outranks a socket on its way out; an established connection clears the record, which is the counterpart. In the same window the cause of the ending was not yet recorded, so the answer named a consumer disconnect - the one thing that had not happened - and it is now recorded before the ending is announced rather than after. Found by CI: the whole suite passed on the development machine, where the socket usually closed in time
    • Connect() no longer reports success off a socket that is on its way out. It opened with a fast path on IsConnected() alone, and in the window above the socket is still open - so a consumer reacting to a notification whose message ends "Call Connect() to retry" was told they were already connected, and nothing was started. The fast path now asks what the wait asks, under the same lock: an open socket, no permanent disconnect, and no recorded ending. Found by CodeRabbit on the commit that fixed the defect above
    • the ownership guard reached the four announcements that still lacked it. A review found one - the reconnect loop's own catch, which checks ownership a line above the announcement rather than in the same critical section, so a takeover landing between the two overwrote the winner's state with a RestoringConnection from a loop that had already been superseded. Rather than fix the instance, every announcement in the file was audited: the fast reconnect's two, the handler-failure path that has not given up yet, and the one reported. Announcements that speak for the client as a whole rather than one transition - Connecting from the takeover that just took it, the Disconnect() paths, Connected from a connection that just opened - carry no generation by design
    • the ownership guard is now the compiler's job. After the audit above, every status announcement carried its generation, so the parameter became required rather than optional. This is the only change of the set that closes the class instead of an instance: five reviews found five sites of it on this branch, one at a time. It can no longer be omitted - only passed wrongly, which is a smaller mistake and a visible one at the call site. A Connected that really happened is exempt from the spent-sequence seal, because suppressing it would leave the ending standing over a client whose socket is carrying traffic
    • pinned by 46 tests, each asserting a type or a value and never a message. Four of them exist because they failed first: Task.WhenAll does not lose the subtype unless a faulted task is alongside it, a readiness signal armed only on takeover leaves a waiter spinning after a close that took over nothing, and a retry filter that cannot tell the client's own teardown from a peer operation reports a different failure depending on timing

11.4.0.0 07/09/2026

  • A transition of the connection has one owner (#179, the follow-up to #178). Every operation that moves the connection - ChangeServer, Connect, Disconnect, DisconnectAndWaitAsync, the health check's fast reconnect, the reconnect loop and the path taken when an OnConnected handler fails - used to decide for itself what happened to the socket, and two of them running at once were reconciled by ReferenceEquals(ws, ...) checks placed after whichever await somebody had noticed. #178 added three such checks and its review found the next window each time. The checks were right where they were; the pattern was what did not scale.
    • the connection now carries a generation. A consumer command and the fast reconnect begin one, taking the session, the socket, the reconnect loop, the ping timer and the message processor out of their fields in a single critical section; the socket callbacks, the loop and the handler-failure path continue the generation of the socket they run for. An operation that finds the generation moved on stands down - after every await and after every consumer callback - and the one that moved it owns the rest. Disconnect() wins against anything in flight, and an attempt it overtook closes the socket it opened, whether the takeover found that socket installed or the socket came into being afterwards
    • the four windows the issue lists are closed by that one mechanism. A Disconnect() landing in one of ChangeServer's yields no longer gets overridden by the switch resetting it and connecting - the client was online after the consumer took it down. A status handler that answers RestoringConnection with a ChangeServer no longer has its replacement session marked retiring by the fast reconnect that ran the handler. The reconnect loop releases its claim under the same lock the close callback asks under, with the socket re-checked there, so a close processed as the loop exits either sees the loop released or is handled by it - nobody reconnecting is no longer an outcome. And a request is written under the lock the retirement takes the socket under, so a retirement finds it either not yet sent, and refused, or already handed to the socket - it no longer reaches a server the client has left
    • for consumers: a ChangeServer that a later operation overtook reports it instead of returning success from a server the client is not on - NotConnectedException when a Disconnect() won, OperationCanceledException when another ChangeServer or a Connect() did. Connect() keeps its contract: it returns when the client is connected, wherever a concurrent switch took it, and OperationCanceledException still means the caller's own token. Options handed to ChangeServer are validated before the old connection is torn down rather than after
    • the two loose ends from #178 are tied. NotConnectedException thrown bare carries a message that says what it is, and the immediate refusal under RequestFailurePolicy.ImmediateFail names the policy - since #178 that is the exception a request issued during a switch gets, where it used to get a TimeoutException with "Timeout" in it, and a consumer classifying by text had nothing to recognise. WebSocketClient.SendMessage no longer answers a socket that is not open with a Connect() - ConnectAsync on an already used ClientWebSocket throws, the catch disposed the socket and raised OnConnectionError, and the send went ahead regardless - and SendMessageAsync returns a task that faults when the message could not be written, so the request that owns it is rejected at once rather than left to RequestTimeout. Messages are serialized whole on the socket; two concurrent messages larger than the send chunk could interleave their frames before
    • OnSessionEnded is owed whatever wins. A ChangeServer or fast reconnect that a Disconnect() overtakes before it announced the session it retired still announces it - the retirement silenced the socket's own close callback, and nothing else knows the session. Disconnect() announces UserDisconnected itself rather than leaving it to the close callback alone: a Connect() issued right after it installs a new session before the old socket's close is processed, and the callback then filed the close as a stale session and said nothing. And a takeover that finds no socket takes no session either - the session belongs to whoever took the socket, and a Connect() after a Disconnect() used to announce a loss of its own for a session the disconnect was about to announce
    • a fast reconnect no longer runs a second full series after the loop gave up. With StopAfterMaxAttempts, the loop the fast reconnect's failure started ran out of attempts, reported Disconnected and released its source; the fast reconnect's own wait then failed with "failed permanently", which its catch read as one more failure to retry. And a Disconnect() that took a handshake still in flight no longer installs a completion source nobody completes - the cancelled handshake reports no close - so the next DisconnectAndWaitAsync returns at once instead of waiting out its timeout
    • six older defects on the same paths, found by the cold review of this change and fixed with it because the change rewrites the code they live on. OnceOpen reported Connected and started a ping timer nothing would stop after a Disconnect() from inside the OnConnected handler. Connect() after a Disconnect() ran with the intentional-disconnect flag still set, so a server that was down read as "closed permanently" and nothing reconnected - ChangeServer was the only path that cleared it, and the flag now follows the generation. A handshake cancelled by a takeover reported nothing, so its attempt timer went on firing OnConnectionFailed for the dead socket at every ConnectionAttemptTimeout. And Connect() over a socket that was closing announced no session end and swept no requests, both of which the close callback would have done had Connect() not retired the session underneath it
    • a failure of an established connection is reported once. The receive loop routed a failure that was not a network error - a frame the protocol forbids, or in the browser any failure at all, since its ClientWebSocket says nothing recognisable - through the handshake-failure callback as well as the close callback. The first announced "Initial connection failed" for a connection that had been up and in use, with an OnDisconnect that carried no code, and the second reported the real close. Now the close callback is the only reporter: it classifies the failure, announces the session end once and starts the reconnect. In the browser a WebSocketException on an open socket is classified as a network drop - the transport going away is the one failure it has - and an exception with no message is described by its error code
    • a handshake this side cancelled is not reported a second time. The connect-attempt timer and a takeover cancel the socket after reporting, and in the browser the cancelled ConnectAsync throws WebSocketException ("ConnectFailure") rather than OperationCanceledException, which reached the connection-error callback as a second failure of the same attempt. Both found by driving the Blazor test client through a dropped connection and a connect timeout; neither is reachable from the .NET unit suite, where a cancelled handshake throws OperationCanceledException and a dropped connection arrives as a network error
    • the documentation of UseCheckHealth and InactivityTimeout promised more than the code does: it said the health check on its own reconnects after sixty seconds without inbound data, while the inactivity check has always run only with UseCustomPing enabled - and deliberately so, since an idle connection with no subscriptions receives nothing by design, and silence without keepalive pings would declare a healthy socket dead every minute. The behaviour stays; the docs now say what it is. Found by driving the Blazor test client through a connection that stayed open and went silent
    • pinned by tests that issue the second operation from a callback the first one runs, which lands it inside the first one's yields every time: a Disconnect() and a second ChangeServer from the session-ended handler of a ChangeServer, a ChangeServer from the RestoringConnection notification of the fast reconnect, a Disconnect() from the OnConnected handler, a Connect() after a Disconnect() against a server that comes up later, and a server that closes each connection the moment its handshake completes, so the reconnect loop's success and the close it has to survive arrive together

11.3.2.0 06/09/2026

  • A request issued while the client is switching servers no longer hangs until RequestTimeout (#177). Every path that retires a connection - ChangeServer, the ping-triggered fast reconnect, Disconnect, DisconnectAndWaitAsync, and the path taken when an OnConnected handler fails - rejected the pending requests first and cleared the socket reference afterwards. The rejection resumes the consumer, and a consumer that issues its next request from there - the second value of a page load, read from the response handler of the first - found the retired socket still installed, passed the connectivity check on it, and was written into it after the sweep that would have rejected it. Nothing completed it: the sweep had run, and a failed send is report-only. Forty seconds later it timed out, with the connection healthy for thirty-nine of them.

    • the fix clears the socket reference before the sweep. Moving it ahead of the first await, as the issue proposed, is not enough: RequestManager builds its completion sources without RunContinuationsAsynchronously, so on a thread pool the consumer's continuation runs inline, inside the sweep itself, before any await. A fifth retirement path the issue did not list is covered too
    • ImmediateFail now refuses such a request at once with NotConnectedException; WaitForConnection carries it over to the new connection. A request whose send fails because the connection went away between the check and the send is rejected rather than left pending for RequestTimeout
    • for consumers: retry logic that recognised this failure by the TimeoutException it used to produce now sees NotConnectedException (or OperationCanceledException, for a request that was in flight when the switch began) immediately. Classify by type rather than by message
    • the regression test hands the sweep a continuation that runs synchronously and asserts which socket the follow-up saw
  • ChangeServer, the fast reconnect and Disconnect no longer stall a single-threaded host for two seconds. Stopping the stream-message processor blocked the calling thread on its reader task, with a two-second cap. On Blazor WebAssembly the reader's continuation needs the very thread that was blocked, so the cap was always reached: every server switch froze the UI for two seconds, and a wake-from-background reconnect is such a switch. The stop is awaited now, after the request sweep, and the reader is gone in milliseconds. Measured on the WebAssembly test client: 2000 ms to 5-390 ms per switch.

  • A ping-triggered reconnect no longer waits three seconds for itself. RetireCurrentSessionAndReconnectAsync runs inside the ping check that calls it, and waited for the ping to finish before retiring the session - its own, whose flag could not clear until it returned. Every reconnect the health check started paid the full WaitForPingToFinishAsync timeout before announcing that the session had ended. The wait now recognises the ping it runs in. RestoringConnection to OnSessionEnded on the stand: 6 s to 20 ms.

  • A reconnect the loop finished is not reconnected a second time. When the fast reconnect's own attempt failed at the socket, the failure callback started the reconnect loop on the same cancellation source; the loop connected first, OnceOpen retired the source, and the fast reconnect's wait came back cancelled. Its catch read that as a failure: it reported RestoringConnection on a client that was connected and started a second loop, whose first attempt retired the live socket and opened another. Consumers saw two OnConnected per recovery, with a spurious RestoringConnection between them, and restored their subscriptions twice. A connected client is now recognised as settled, and the loop no longer retires a socket that is open when its turn comes.

    • pinned by a test that takes the server down at RestoringConnection, so the sequence falls to the loop, brings a replacement up on the same port and requires exactly one connection afterwards

11.3.1.0 05/09/2026

  • FundWallet no longer reports success for a wallet the faucet never paid (#174). The starting balance was read before the faucet was asked, and any failure to read it left it at zero; the wait then asked whether the balance had risen above that zero, so a wallet that already held funds satisfied the test and the call returned Funded with the balance the account had all along. Found three times independently while cold-reviewing the previous release, by three reviewers on two models.

    • the fix is not a better baseline, it is not needing one. The faucet names the payment it sent - transactionHash, which both the devnet and testnet faucets return and this library was ignoring - so the wait now asks the ledger about that transaction and requires a tes result. Nothing compares balances, so a balance the account already held cannot stand in for a payment that never arrived
    • a case the comparison could not see at all is covered by the same change: the faucet's payment reaching a ledger and failing there. That was twenty seconds of waiting and "the balance did not rise"; it is now the result code and the hash, immediately
    • the hash is checked rather than believed. It is the faucet's claim about what it did, and any validated transaction on the ledger satisfies a lookup - including one that pays somebody else - so the transaction it names has to deliver to the wallet being funded, a validated transaction with no result code at all is refused rather than passed, and a faucet that answers about a different account than the one it was asked to fund is refused before any of that. Raised on the pull request by CodeRabbit, and the same mistake this entry is about: a proxy accepted without checking what it stands for
    • where a faucet does not name its payment, the balance comparison remains - with a baseline that no longer lies. Baseline carries whether the number is a measurement: an account the ledger does not have holds nothing and that is an answer (XrplErrorCategory.NotFound, using the classifier the SDK already had), while a read that failed for any other reason leaves no baseline, and the call then refuses rather than guessing in the caller's favour
  • xAddress is no longer masked out of a faucet diagnostic. It went on the redaction list in 11.3.0.0 on the assumption that it might carry something; the live responses show it is the X-address form of the funded account, so masking it removed a useful diagnostic from an error message and protected nothing. The seed-bearing names stay masked.

  • The faucet response model matched fields the faucets do not send. FaucetWallet.Balance and FaucetAccount.Secret were mapped and were always 0 and null: checked against the live devnet and testnet faucets, the response is account (xAddress, address, classicAddress), amount, transactionHash, and a top-level seed only when no destination is given. Both dead properties are removed and TransactionHash is added. This ships as a patch rather than a minor because no public method returns either type - an instance could only have been constructed by the caller, and the two members it could have read never held anything.

11.3.0.0 05/09/2026

  • EasyTimer is gone (breaking). The class sat in Xrpl.Wallet as a pair of System.Timers.Timer wrappers named after JavaScript's setInterval and setTimeout, because the file around it is a port of xrpl.js's fundWallet.ts. Nothing ever called it: across every revision of FundWallet.cs back to October 2022 there is not one use, and the faucet poll it was presumably written for drove a System.Timers.Timer through static fields instead - the design that produced the once-per-process poll budget fixed in 11.3.0.0. It is removed rather than deprecated because there is nothing to migrate to that is not already better: System.Timers.Timer is public, documented and one line away.

    • for anyone who did reference it, the wrappers were not worth keeping. Neither could report a failure - the callback ran on a timer thread with nothing to observe it - SetInterval let callbacks overlap when the work outlasted the interval, and stopping either one depended on the caller holding the returned handle
  • The Sponsor field is exercised on the Vault and Loan transaction types, and on the bridge attestations. These are the types rippled forbids inside a Batch (Batch::preflight kDisabledTxTypes), so whether they take a sponsor at all was worth establishing rather than assuming. They do: preflight1Sponsor in Transactor.cpp constrains only spfSponsorReserve, through the allow-list in isReserveSponsorAllowed, and no Vault or Loan type is on it. Fee sponsorship is unconstrained.

    • a sponsored LoanSet carries three signatures at once - the broker's own, the borrower's CounterpartySignature and the sponsor's SponsorSignature - and this is the first time the composer has had to place all three in one transaction
    • LoanBrokerCoverWithdraw, LoanBrokerCoverClawback and LoanManage had never been submitted to a node by any test. Clawing broker cover back is the asset issuer's move and rippled refuses it on a native asset, so that case needs an IOU-backed vault whose issuer is a third account
    • two rules from LoanBrokerSet::preflight that are easy to trip, now written down where the test lives: VaultID is required even when the transaction updates a broker that already exists, and a transaction naming a LoanBrokerID may not carry ManagementFeeRate, CoverRateMinimum or CoverRateLiquidation - those are set once, at creation, and an update carrying one is temINVALID
    • VaultDelete.MemoData is left out. The field is optional on rippled's develop branch and the release build the CI stand runs answers temDISABLED for it, so a test carrying it would report the stand's version rather than anything about the SDK
  • FundWallet works more than once per process. The faucet helper polled for the funded balance through a System.Timers.Timer driven by static fields - the poll budget, the address, the two balances and the result - and the budget was initialised once and never reset. It bought twenty polls for the lifetime of the process: the first few wallets were funded, and from then on every call reported Unable to fund address with faucet after waiting 1 * 20 seconds without polling at all. Anyone funding a second wallet on testnet or devnet hit this, which is every integration suite and most tutorials.

    • the concurrent case was the dangerous half. Two overlapping calls overwrote each other's address and result, so a caller could be handed another wallet's balance and treat an unfunded wallet as funded. All of that state now belongs to the call.
    • the timer went with it. The callback was async void, so the exception it raised on giving up could not be caught by the caller, and the wait loop blocked its thread on Task.Delay(...).Wait() inside an async method. It is an ordinary awaited loop now.
    • the balance was polled twice per wallet. The faucet answers with the destination account, so the second poll re-read an address that had just been read, costing a round trip and another interval.
    • found by running the integration suite against devnet rather than the standalone stand, where nothing calls the faucet: 21 of 22 sponsored-type tests failed on it. The fix is verified the same way - one process, some sixty faucet calls, all funded. There is no unit test because the faucet call news up its own HttpClient, so the helper cannot be exercised without the network
  • Reading a ledger no longer fails because someone else's Oracle holds bytes we would not have written. The converters for an Oracle's Provider, AssetClass and URI, and for a nonstandard currency code, required the decoded bytes to be printable ASCII and threw a JsonException otherwise. rippled imposes no such rule: OracleSet::preflight checks the length of those fields and nothing else, so they are Blob fields carrying arbitrary bytes, and a currency code is 160 bits the ledger does not constrain either. Because the check ran inside a JsonConverter, one such value did not fail one field, it threw out of the whole response - a single third-party oracle in a ledger_data page made the page unreadable. Found on devnet, where other people's oracles exist; the standalone stand only ever holds our own.

    • reading is total now: bytes that are text are decoded, and anything else comes back as the hex the node sent. Writing still requires printable ASCII, so a value that came back as hex is not something to hand straight back
    • the test that pinned the old behaviour asserted the throw. It now asserts the value survives, and a nonstandard currency code the SDK cannot render as text is pinned the same way
  • A test that signs on the node no longer does so on a node someone else runs. TestIMemoLimits hears rippled refuse an over-length memo, and to hear it the node has to sign the transaction, because the SDK's own rules stop it before a signature exists. Node-side signing puts the wallet's seed on the wire. That was fine while the only reachable node was on this machine; once the profile could point at devnet it was not, so the test says standalone only. The rule it checks is rippled's and does not vary by network.

  • A nonstandard currency code whose padding is not padding is no longer read as text. DecodeOracleCurrency stopped at the first zero byte without checking that the rest were zero, so 5553440001... read as USD and would have lost the 0x01 on the way back out, and twenty zero bytes read as an empty string. Both come back as the hex the node sent now.

  • SignatureComposer.ComposeSignatures keeps its two-argument form as an overload. The counterparty routing above needed a third argument, and giving it a default would have been source-compatible but not binary-compatible: an assembly compiled against the two-argument signature emits a call to a method that would no longer exist, so it would fail at run time rather than at build. Both forms are pinned.

  • A Batch with one inner transaction is refused before it reaches a node. rippled Batch::preflight answers temARRAY_EMPTY to fewer than two inners - the same code as for none at all - while the SDK's Validation.ValidateBatch only refused an empty RawTransactions. Five of sixteen new inner-type batches failed on the stand that way before the rule was found. The validation now says what the node would: at least two, at most eight.

  • A LoanSet borrower with a SignerList can co-sign. CounterpartySignature (XLS-66) accepts the multisig form - an empty SigningPubKey and a Signers array that rippled checks against the counterparty's SignerList over the same multisign preimage as tx.Signers (STTx::checkMultiSign with the inner object) - but nothing in the SDK could produce it. Each signer of the borrower's list now signs with the standard Sign(tx, multisign: true), and the composer places the entries:

    • IXrplClient.ComposeSignatures looks the Counterparty's SignerList up alongside the Account's and the Sponsor's, routes the entries into CounterpartySignature.Signers, and pre-checks the quorum by weight so a short set fails with a readable message instead of tefBAD_QUORUM
    • SignatureComposer.ComposeSignatures(parts, sponsorSignerAccounts, counterpartySignerAccounts) does the same offline, and LoanSigningHelper.CombineLoanSignatures(parts, counterpartySignerAccounts) is the LoanSet-shaped entry to it
    • the fee has to cover the signers: rippled LoanSet::calculateBaseFee charges one base fee per entry in CounterpartySignature.Signers, so autofill with signersCount set to the borrower's signer count before anyone signs
    • pinned on the standalone node end to end: a 2-of-2 borrower, ledger-routed and offline composition, and the below-quorum refusal (TestILoanMultisig)
  • Witness-side signing of bridge attestations (XLS-38). XChainAddClaimAttestation and XChainAddAccountCreateAttestation were modelled but nothing could fill their PublicKey and Signature: a witness signs the canonical serialization of an STObject holding the attested facts, with no hash prefix and no transaction fields (rippled AttestationClaim::message / AttestationCreateAccount::message). XChainAttestationSigner builds those bytes from the attestation transaction's own fields, signs them with the witness wallet, and verifies a received attestation the way attestationPreflight does.

    • the byte layout is pinned field by field from the XRPL binary format, independent of the SDK's codec (TestUXChainAttestationSigner): the field order rippled assigns is the canonical sort order, so a codec regression on STXChainBridge or on field ordering fails there rather than as temXCHAIN_BAD_PROOF on a node
    • the whole witness half now runs against one standalone node (TestIXChainAttestation): rippled resolves a bridge spec to the locking-side entry first, so with only that entry on the ledger a commit locks funds in the door and an attestation with WasLockingChainSend = 0 releases them here - delivery on quorum with a Destination, an explicit XChainClaim with a DestinationTag without one, an unlisted witness refused with tecNO_PERMISSION, and account creation reaching quorum across two witnesses through an XChainOwnedCreateAccountClaimID
  • The integration suite runs against any node, not only the standalone stand. Every TestI* class hard-coded TestNodeType.Standalone and the genesis account for funding; XRPL_TEST_NODE selected nothing. The profile now comes from the environment - XRPL_TEST_NODE (standalone, devnet, testnet) picks the funding policy and whether ledger_accept is issued, XRPL_TEST_NODE_URL overrides the WebSocket URL for a stand on other ports or a private node - and public networks fund wallets straight from the faucet with retries. The new devnet-coverage.yml workflow (manual dispatch, never CI) runs the coverage-oriented classes against devnet, where the XRPL Foundation amendment dashboard scores each amendment by the validated transactions that exercised its surface.

    • three matrix classes exercise the SDK's transaction surface end to end rather than one feature at a time: every transaction type as a Batch inner, read back by its computed id (TestIBatchInnerTypes); the Sponsor field on every transaction type other than Payment, sponsor co-signing (TestISponsoredTypes); every AMM transaction type over an MPT asset, including the lsfMPTAMM entry flag on the pool account (TestIAMMMpt, formerly TestIAMMCreateMpt)
    • a time gate in rippled is now > mark, not now >= mark (after() in View.cpp), so a wait that stops on equality is still a tick early. The escrow batch case waited that way and its EscrowCancel inner came in one close time short, which under tfAllOrNothing reverted the batch and made the sibling EscrowFinish vanish too. Standalone close times move in coarse steps and land on equality readily; devnet's next step arrived within seconds and hid it
    • two protocol facts those matrices surfaced, now written down where the tests live: a DID entry carries no Sponsor field, so spfSponsorReserve on DIDSet is temINVALID_FLAG; and the Sponsorship entry lands in the sponsee's owner directory too, so asfAllowTrustLineClawback must be set before the sponsorship exists
    • AMM, AMMClawback, MPTokensV1 and XChainBridge classes are gated by AmendmentGuard like the others, so they skip on a network without the amendment instead of failing. MPTokensV2 is a [features] preset on the standalone stands and invisible to the on-ledger guard, so TestIAMMMpt runs there unconditionally
    • no integration test derives its accounts from a fixed phrase any more. Four classes built wallets with XrplWallet.FromNormalizedText("primary test account") and the like. A phrase is the same account on every network, so on a public one it is shared with everyone who ever ran the same test: the state a test starts from is whatever they left behind, and TestIBatch and TestIMultisign disable a master key on one of those accounts. It bit on the standalone stand too, where a derived account kept its state between runs and let a "create" test pass as a modify - the five DID tests each created a DID that a previous run had already created
    • Utils.TestTransaction verified nothing. The helper behind about forty of the older integration tests looked the submitted transaction up once, immediately after submission, and then discarded the response without asserting anything about it. It read as a verification step and was not one. On the standalone stand the caller had just forced a ledger close, so the lookup happened to find something; on any network where ledgers close on their own it raced. It waits for the transaction to reach a ledger now and checks the result recorded there
    • the wait for a close time is bounded. A ledger that stops advancing is a node failure, and an unbounded poll reports nothing about it; the failure now names the last close time seen and how far short of the mark it was, which separates a stalled node from a mark set too far ahead
    • path finding is answered from a ledger snapshot rippled keeps for it, which can lag the validated ledger that funding confirmed the account on. A freshly created source is then absent from path finding for a few ledgers after it plainly exists, so those requests retry while the node answers srcActNotFound
    • a public endpoint is a cluster behind one name, so an account funded over one connection may not be visible on a second one yet. The path-finding tests are the only ones that open a second client and they hit srcActNotFound on it intermittently; they wait for the account there now
    • faucet calls are no longer fully serialised. The limit of one dated from a shared filler wallet whose sequence could not take concurrency; each call now funds its own destination and shares nothing, and serialising them spent minutes that individual tests were charged for inside their own timeouts
    • two tests are standalone-only by construction and say so instead of failing on a public network: TestIAdminCredentials needs the stand's own [port_ws_admin_auth], and TestIAccountDelete forces the 256 ledger closes rippled requires before an account can be deleted
    • the 18 Batch tests asserted on a provisional result. They checked the engine result of the submission, which says what one node made of the transaction against its open ledger, not what the network settled on, and they accepted terQUEUED, which says the transaction was not applied at all. A batch that never reached a ledger passed. They now wait for the transaction to appear in a ledger and check the result recorded there. That also settles the account sequences between tests: the old assertion returned before the submission was applied, so the next test autofilled against a ledger without it and got tefPAST_SEQ, which is how this surfaced on devnet
    • an outer Batch validating with tesSUCCESS does not mean its inner transactions applied. Under tfAllOrNothing a failing inner makes rippled discard the whole batch view (apply.cpp), so nothing is committed, the failing inner's own result is recorded nowhere, and the outer still validates successfully because Batch::doApply returns tesSUCCESS regardless. A caller reading only the outer result cannot tell the two apart. TestIBatchInnerTypes says so when an inner is missing, and its escrow case moved to tfIndependent, where each inner records its own result
    • reading an inner batch transaction back by its computed id is a poll now. The outer Batch is validated by then and its inners were applied in the same ledger, but the node answers txnNotFound for a short window before they are queryable, and a single attempt made TestIBatchInnerTypes fail intermittently
    • a public faucet hands out a fixed 100 XRP per call, less than a single account needs in some flows (a lending broker funds a 100 XRP vault and a 50 XRP cover), so EnsureBalanceAsync tops an account up to a stated minimum instead of assuming one call is enough
    • TestILedgerStateFix submitted with fail_hard, which drops a tec result from the open ledger, so its tecFAILED_PROCESSING never reached a validated ledger and proved nothing about the node accepting the transaction. It now goes in without the flag and is validated like any other
  • A transaction with no TransactionType reads back as Unknown instead of AccountSet. TransactionTypeConverter maps an unrecognised type name to TransactionType.Unknown, but it only runs when the field is there to read. When it is absent, TransactionRequestConverter and TransactionResponseConverter still built their sentinel object and nothing ever assigned the property, so it kept default(TransactionType) - and the enum's first member is AccountSet, not Unknown. The result was a concrete wrong type rather than a missing one: a caller inspecting TransactionType was told AccountSet about a transaction that never said so. Both sentinels now set the property in their constructor, which also covers an unrecognised name reaching them through JsonSerializerOptions assembled without XrplJsonOptions.Default.

    • the enum is left as it is. AccountSet has held the implicit 0 since the type was introduced, and reordering to put Unknown there would silently change every value a consumer has stored as a number. The enum is not on the signing path either - the codec builds a blob from definitions.json, not from this type
  • The refusals of XLS-38 are covered, and two of them were the SDK's own footguns. TestIXChainNegative submits eight cross-chain transactions the library is willing to sign and asserts the code the ledger records: a reversed attestation direction (tecXCHAIN_WRONG_CHAIN), an attestation about a sender the claim id does not name (tecXCHAIN_SENDING_ACCOUNT_MISMATCH), a signature made with a key that is not the claimed signer's (tecXCHAIN_BAD_PUBLIC_KEY_ACCOUNT_PAIR), a claim short of quorum (tecXCHAIN_CLAIM_NO_QUORUM), a claim by an account that does not own the claim id (tecXCHAIN_BAD_CLAIM_ID), a claim id offering the wrong reward (tecXCHAIN_REWARD_MISMATCH), an untagged claim to an account that requires a tag (tecDST_TAG_NEEDED), and an account-creating commit below the bridge minimum (tecXCHAIN_INSUFF_CREATE_AMOUNT). A regression in XChainAttestationSigner that still produced a well-formed signature would pass the happy-path class and fail here.

    • two of the eight were written wrong first, and the node said so. XChainClaim.Amount carries the issue of the chain the claim is paid on - the locking chain issuer, not the issuing chain door - and getting that wrong is tecXCHAIN_BAD_TRANSFER_ISSUE rather than the code under test. And WasLockingChainSend does not only pick a direction: attestationPreflight derives the issue it expects on the attested amount from it (bridgeSpec.issue(srcChain(...))), so flipping the flag alone is a malformed proof, refused before the check it was meant to reach. Both are written down where they bit
    • tecXCHAIN_PROOF_UNKNOWN_KEY is deliberately absent: checkAttestationPublicKey answers tecNO_PERMISSION in preclaim first, and the code itself belongs to the path that filters a batch of attestations, which a single transaction never takes
    • the bridge harness moved to TestIXChainBridgeBase so both XChain classes build their stand the same way, and it now takes a quorum and a witness count
  • A faucet failure says what failed. ProcessSuccessfulResponse caught everything and rebuilt it as new XRPLFaucetException(err.Message) - the message only. The original exception type, its own cause and the whole stack trace were dropped, so a DNS failure, a JSON error and a node that refused the balance read all arrived as one flat sentence. The cause travels with it now, which needed a constructor: XRPLFaucetException had only (string), though its base XrplException has taken an inner exception all along.

    • the catch had a branch that could not run - if (err is Exception) on a variable declared Exception, with the real handling in the unreachable half - and it caught the XRPLFaucetException thrown a few lines above inside the same try, rebuilding it from its own message and resetting its stack. Both are gone
    • OperationCanceledException was swallowed the same way, so a caller who cancelled was told the faucet had failed. It passes through now, and there is a CancellationToken to cancel with: FundWallet(client, wallet, faucetHost, cancellationToken) is a new overload rather than a defaulted parameter, which would have been source-compatible but not binary-compatible. The token reaches the delay, the POST, the body read and GetXrpBalance - which had accepted one all along; the chain simply stopped here
    • reading the faucet's body is now ReadFaucetAddress, and each way it can disappoint names itself: a body that is not JSON keeps the parse failure as the cause, and one without an account quotes what came back instead of failing later as a NullReferenceException. That last case was reachable - JsonSerializer.Deserialize hands back null for a null literal, and the old code went straight to faucetWallet.Account. Nine tests cover it, the first this file has had that do not need a network
  • A faucet failure no longer repeats the funded wallet's seed back to the log. Quoting the response body in the exception message is worth it - a rate limit says so in the body - but a faucet response can carry a seed, and an exception message is the one thing a caller is certain to log. (Corrected in 11.3.1.0: the faucets return the seed as a top-level seed, and only when no destination is sent - which this library always sends - so the leak was latent rather than live. The redaction is by key name and covers it either way.) The value of anything named secret, seed, master_seed, private_key or passphrase is masked before the body reaches a message, and the quote is capped at 512 characters. Raised on the pull request by CodeRabbit; the tests fail against the unredacted version.

  • The faucet call disposes what it opens, and stops opening a client per call. ReturnPromise newed an HttpClient for every faucet request and disposed neither it, the request content nor the response - a new client per call holds its socket past disposal and exhausts the pool under load. There is one client for the process now, with the faucet host on the request rather than on the client because it varies, and the content and response are scoped with using so the paths that throw release them too.

  • A generated wallet is only yours if the call succeeds, and the documentation now says so. Passing no wallet means one is generated inside FundWallet, and the returned Funded is the only reference to it. The faucet may already have created and paid that account by the time the call fails - or, now that there is a token, is cancelled - and the seed goes with the stack frame: the funds are unreachable and a retry strands another account against the same quota. Handing the wallet back on the failure path would mean putting it on the exception, which is the object callers log, so the remark says what the API already allows instead: supply a wallet whenever the answer has to survive a failure.

  • A reconnect during the faucet wait is waited out, not given up on. The poll retried an XrplException - the account not being on the ledger yet, and a request timeout - for its full twenty-second budget, but a connection that dropped and was coming back arrives as the token-less OperationCanceledException above, which neither catch matched. So the more recoverable of the two events ended the call about a second in, on a wallet the faucet had funded, and a caller with its own retry burned a second faucet call into the rate limit. One catch clause with the rule named in it now covers both.

  • The shared faucet client retires its pooled connections. Making it process-wide fixed a socket leak and introduced a smaller problem in its place: the default handler never expires a pooled connection, so the address resolved on the first call is pinned for the life of the process, and a faucet host that moves is unreachable until restart. The per-call client this replaced re-resolved every time only by accident of being new. PooledConnectionLifetime is two minutes.

  • A dropped connection is no longer reported as a cancellation. OperationCanceledException does not mean the caller gave up: RequestManager.RejectAllWithCancellation builds one with no token behind it and rejects every pending request with it, and connection.cs calls that from seven places, the disconnect and ping-timeout paths among them. Two catch filters here asked the type rather than the token, so a socket that dropped mid-wait ended FundWallet's task cancelled rather than faulted - past every catch (XRPLFaucetException), and read by any caller that treats cancellation as "the user aborted, do not retry" as a reason to stop. A drop while the starting balance was being read was worse: it abandoned the call before the faucet was ever asked, where previously it left the balance at zero and carried on. Both filters ask IsCallerCancellation now, which is what the HTTP side had already been doing.

  • The wait for a faucet payment says why it gave up. The poll swallowed every failed balance read with continue - correct at first, because the account is not on the ledger until the payment validates - and kept nothing. So when the balance never rose, the exception blamed the faucet even if this process had been disconnected from the node for the whole twenty seconds, and the cause-preserving catch above it could never fire for a node or network failure, which is most of them. The poll now returns the last read failure beside the balance and it becomes the InnerException when nothing arrived. It does not pick the wording: an account the faucet never paid answers actNotFound on every attempt, so a failure is present in exactly the ordinary case, and a message that chose on that basis said the balance could not be read precisely when the ledger had answered every time. One sentence, always true - the balance did not rise - with the cause attached.

  • An HttpClient timeout is no longer reported as a cancellation. Only HttpRequestException was converted into XRPLFaucetException, but the client reports its own 100-second Timeout as a TaskCanceledException - an OperationCanceledException, indistinguishable by type from a caller who cancelled. A faucet host that accepted the connection and never answered therefore escaped as a cancellation the caller had not asked for, past every catch (XRPLFaucetException). The token now decides: only the caller's own cancellation passes through. The body read, which had no handler at all, is covered the same way.

  • Two faucet diagnostics that reported nothing. The message for a non-JSON answer interpolated Dictionary<string, object>.ToString(), so it printed the type name and never the status, content type or body it had been built from. And an answer with no Content-Type header threw InvalidOperationException out of GetValues - a response without that header is still a response, a proxy in front of the faucet can send one - which reported the wrong failure about the wrong party. The status line and body are in the message now, and the header is read with TryGetValues.

    • an unsuccessful HTTP status was written to Console from library code and then ignored, so the failure surfaced later as something else. It is an exception carrying the status and the body. The response body was also read twice, once as a string for that console line and once as bytes

11.2.0.0 01/09/2026

  • NormalizeInnerTransaction no longer rewrites the transaction it is given (#157). The method strips TxnSignature, Signers and LastLedgerSequence and overwrites Fee, SigningPubKey and Flags. It did that to the caller's own JsonObject and returned that same instance, so anything a consumer held and passed in came back altered. It now normalises a copy and leaves the argument alone.

    • the two overloads no longer disagree. NormalizeInnerTransaction(object) rewrote its argument when the runtime type happened to be a JsonObject and did not when it was anything else - the same call, with aliasing decided by a type test the caller cannot see
    • SignAsBatchPart depended on the mutation, and not visibly. It normalises each inner transaction, hashes the results into the batch preimage, and finally encodes outer into the blob - and the normalised fields reached that blob only because normalisation rewrote the objects living inside outer. The call site read as though it merely collected a list for the txIDs. It now writes the normalised transaction back explicitly, which is what the old code achieved by side effect
    • nothing pinned any of this. The batch fixture supplied inner transactions that already carried Fee = "0", SigningPubKey = "" and tfInnerBatchTxn, so normalisation was a no-op on them: making the method return a copy left all 1215 unit tests green while the emitted blob carried inner transactions the signature never committed to. Both halves are now pinned - that the blob carries normalised inners, and that the signature covers a preimage built from them (#158)
    • a caller who relied on the old behaviour was relying on something the documentation denied until it was corrected in #156
  • A malformed RawTransactions entry is named instead of failing inside a converter (#160). An element that is not a JSON object was refused by System.Text.Json as Expected StartObject token thrown from DictionaryObjectConverter - the first thing a caller saw about a malformed batch, naming neither the field nor the position, while every other malformed input on this path answers with a ValidationException that says what is wrong. GetBatchSignerAccounts, the gate every batch-signing path reaches before any XLS-56 check, now refuses it as RawTransactions[i] must be an object., and the same holds one level down for RawTransactions[i].RawTransaction.

    • an element is judged by what it serializes to, never by its runtime type. A JsonArray built through Add<T> holds a JsonValue rather than a JsonObject and still writes a JSON object; testing the node type would have refused input the old code accepted, and would have accepted or rejected the same object depending on whether it arrived in a JsonArray or a List<object>
    • SignAsBatchPart no longer filters its inner-transaction loop on n is JsonObject either. That guard cannot fire - the gate above refuses such an element first, shown by mutation - but a silent skip is the wrong thing to leave behind, and every other malformed input in that loop is refused rather than dropped
    • Batch.Validate called such an element null in its message when it was, for instance, a string. It now says what is actually wrong
  • GetBatchSignerAccounts no longer rewrites the batch it is asked to report on (#161). The method returns the root account and the accounts required to sign, and it also replaced RawTransactions[i].RawTransaction in the caller's own dictionary with a converted copy - the IEnumerable branch aliases an element that is already a Dictionary, so the assignment landed in the caller's object. It is the gate every batch-signing path reaches through VerifyBatchSubmitter, so this happened on every signature.

    • the conversion is still made, for reading; only the store-back is gone. No consumer needed it: every reader of RawTransaction works from a JsonNode built by re-serializing the transaction, not from the dictionary that was passed in
    • that the two representations sign identically is now pinned by a test of its own, since it is the property that makes dropping the store-back safe rather than merely tidy

11.1.0.0 27/08/2026

  • The signing path builds its JsonSerializerOptions once (#147). XrplBinaryCodec.ObjectToJsonNode constructed a fresh instance on every call, and every signing operation goes through it - Encode, EncodeForSigning, EncodeForSigningClaim and EncodeForMultiSigning all route there. Measured end to end on EncodeForSigning, 50 000 calls: 1075.8 ms and 14458 B/op before, 621.8 ms and 13601 B/op after - 1.73x, and 857 fewer bytes each call. The encoded blob is unchanged, hashing identically either way.

    • not the catastrophe this bug is usually described as: since .NET 7 System.Text.Json shares a caching context between structurally equal options instances, so type metadata was not being rebuilt per call - had it been, the gap would be orders of magnitude rather than 1.7x. What was paid is an allocation and a structural-equality lookup in a pool capped at 64 contexts
    • LOVault.ToHex had the same pattern on a colder path
    • Xrpl.BinaryCodec moves to 11.0.1.0 for it. The other base packages are untouched and stay where they are - they are consumed by ProjectReference, so a package built at a newer version keeps depending on the published ones
  • An amount the ledger allows but decimal cannot hold is refused, not guessed at (#148). Currency.ValueAsNumber answered such values three different ways: a positive one clamped to decimal.MaxValue, a negative one threw FormatException, and a very small one quietly became zero. XRPL issued currency runs from 1e-81 to roughly 1e96 - a 16-digit mantissa with an exponent in [-96, 80], per rippled's STAmount - while decimal stops near 7.9e28, so this cannot be parsed away; the only choice is how to fail.

    • the clamp is gone. An amount above the range now throws AmountOutOfRangeException, which carries the value as the node sent it. Returning 7.9e28 for 1e96 is wrong by 67 orders of magnitude, and it did not stay contained: GetBalanceChanges subtracts two balances, so the clamped value went on to throw OverflowException from arithmetic instead
    • the negative case was a parse bug. The fallback's NumberStyles expression came to AllowExponent | AllowDecimalPoint - AllowLeadingSign was missing, so no negative value could reach the branch meant to handle it. The primary parse was correct all along, despite six & terms that all evaluate to zero
    • an amount below 1e-28 still returns zero, and the asymmetry is deliberate. A balance of 1e-81 rounded to zero is zero at any scale a caller can act on; failing over it would cost more than it protects. An amount of 1e96 reported as 7.9e28 is not in that category
    • the threshold is nowhere near the protocol's ceiling: 1e29 is barely above decimal.MaxValue and was already unreachable. A token with a large supply meets this without going anywhere near the ledger's limits
    • Offer.AmountEach reads the same property on both sides of an order and divides them. Anyone may place an offer in their own token at any value the protocol allows, so it fails the same way - and used to return a plausible-looking exchange rate that was wrong by 67 orders of magnitude, without throwing. Both it and GetBalanceChanges now say so in their own documentation rather than leaving it to be discovered
    • Currency.ToString() falls back to the raw value rather than letting the getter throw through it. By convention ToString does not throw, and logging, string interpolation and a debugger's watch window are exactly where someone would be while working out why an amount is unusual - failing there hides the value at the moment it is most wanted
    • the setter no longer writes a string it cannot read back. G16 keeps the ledger's sixteen significant digits and rounds to nearest - which is what rippled does, so it stays - but at the top of decimal's own range rounding to nearest rounds up, past what the type holds. Only there is the sixteenth digit truncated instead, which cannot overflow because dropping digits only moves a number toward zero
    • dust survives. Balances like 0.000000000000000001 do arrive from the network and the SDK must be able to send them back; they are safe because the ledger's limit is sixteen significant digits and dust carries one. Pinned by a test, because the obvious way to bound precision - truncating to sixteen decimal places - turns 1e-18 into zero and would make a remainder disappear silently
    • a test that claimed a round trip can never increase a value was asserting something the protocol does not promise, on a value where it could not fail. rippled's Number defaults to ToNearest, so an amount beyond sixteen digits can legitimately come back larger. Replaced with the property that does hold - an amount already at ledger precision goes out unchanged - and with one stating the rounding, so the next reader does not reach for truncation and move the SDK away from rippled
    • why the setter rounds while the binary codec refuses more than sixteen digits outright is now written down. The two see different inputs: seventeen digits cannot arrive from the network, so the codec only ever meets a hand-written string, while the setter meets computed decimals that routinely carry 28 - AmmMath returns them
    • Console.WriteLine(exception) is out of the parse path. A library does not write to the console
    • breaking in effect, if not in signature: code that read an out-of-range amount used to get a number and now gets an exception. Representing the full range instead of refusing it is #150

11.0.0.0 26/08/2026

Migration at a glance

This release makes the SDK stop misrepresenting what a node sent. It is a deliberate break with no [Obsolete] bridges — the same policy as Path.TypeHex in 10.11.0.0. Everything below is described in detail further down; this table is what will not compile.

What changes Was Now
43 client members Task<AccountInfo> AccountInfo(...) Task<XrplResponse<AccountInfo>> AccountInfo(...) — read .Result, or var (info, raw) = await ...
tx lookup client.Tx(request) client.TxV1(request) — the version is now stated in the name
Response envelope BaseResponse.Result (object) RawResult — the bytes as sent
Envelope id and echo BaseResponse.Id, ErrorResponse.Request (object) RawId, RawRequest
Socket entry point RequestManager.HandleResponse(ReadOnlySpan<byte>) HandleResponse(byte[]) — a span cannot be stored, and the frame must outlive the call
Model value properties uint Sequence uint? Sequence — on ledger models, transaction models, their I* interfaces and request classes
15 more response properties non-nullable nullable — LOBaseLedger.LedgerIndex, HashOrTransaction.LedgerTransaction.Validated, BaseLedgerEntity.Closed/IBaseLedgerEntity.Closed, NoRippleCheck.LedgerCurrentIndex, ServerFeatures.LedgerIndex/Validated, LedgerStreamResponse.LedgerIndex/ReserveBase/ReserveInc/FeeBase/FeeRef/LedgerTime/TxnCount, LedgerStream.FeeRef, ValidationStream.LedgerIndex
Quorum helper PickWalletsForQuorum returned (List<XrplWallet>, uint) returns (List<XrplWallet>, uint, uint) and throws ValidationException when SignerQuorum is absent
Untyped request Task<Dictionary<string, object>> Request(...) Task<XrplResponse<Dictionary<string, object>>> Request(...)
Signing helper GetSignedTx(tx, autofill, failHard, wallet, ...) GetSignedTx(tx, autofill, wallet, ...)failHard never did anything here; pass it to Submit/SubmitAndWait, which do submit
Unknown member in a nested object dropped from the blob in silence InvalidJsonException on the signing path, at any depth
Path step type Xrpl.Models.Methods.Path Xrpl.Models.Common.PathStepList<List<Path>> becomes List<List<PathStep>>
NFTokenAcceptOffer.NFTokenID a property the protocol has no field for removed, from the request, the response and the interface
Model helpers Xrpl.Models.Utils.Index Xrpl.Models.Utils.ModelUtils
Stream events client.connection.OnTransaction += … also client.OnTransaction += … — on IXrplClient now; the old form still works
Custom IXrplClient implementations 16 events 17 — OnSessionEnded has to be implemented; the client and Connection already carry it
Socket close after a user Disconnect() sometimes reported nothing at all OnDisconnect fires every time
IXrplClient.connection { get; set; } { get; } — assigning it would strand handlers on the old object
Dropped stream events invisible client.DroppedStreamMessages counts them; StreamMessageQueueCapacity sizes the queue
Frames from a stale or retiring session delivered as current dropped, and counted by Connection.StaleSessionFramesDropped
Frames dispatched outside the queue invisible counted by Connection.FallbackDispatchedStreamMessages
Frames answered while OnConnected runs dispatched outside the queue, concurrently queued like every other frame
Stream event type LedgerStream.Type was a public field inherited BaseStream.Type property — source-compatible, but an assembly built against the old package needs a rebuild
Stream event type value ResponseStreamType Type ResponseStreamType? Type — an event that carried no type no longer reports UNKNOWN as though the node had said so

Two things are worth knowing before touching the nullable properties, because both bit during this work:

null <  1           // false   — relational operators
(null & flag) != 0  // TRUE    — equality operators, the opposite way

A lifted < silently skipped a signature-quorum check; a lifted != made a credential that was never accepted read as accepted, which was a fail-open in an access check. Where a value is genuinely required, fail loudly; where absence is legitimate, branch on it. ?? 0 by reflex puts the original defect back, moved from serialization into your logic.

Sugar methods (GetXrpBalance, GetLedgerIndex, SubmitAndWait, GetOrderBook, Submit, Autofill) keep their signatures — they return computed values, not node responses. Three of them do gain a way to fail that they did not have before, though: Autofill throws when account_info comes back without a Sequence, GetXrpFreeBalance when it comes back without an OwnerCount, and the quorum helpers when a SignerList has no SignerQuorum. Each of those used to read a non-nullable property that silently defaulted to zero — which is the same lie this release removes from serialization, so failing loudly is the point rather than a side effect.

The raw-response work, all five levels landing together. The problem: a consumer could not get the text a node actually sent — only the typed model — and re-serializing that model differed from the original in both directions, dropping members the model has no property for and inventing zeros for non-nullable CLR ones. Measured on a live ten-entry account_tx at api_version = 2: 156 fabricated members and 28 dropped. After all five levels: 0 fabricated, and every remaining drop is named with a reason and guarded by a test.

The entries below are grouped by what changed, not by the order the levels were built in. The short version of what will not compile is in the table above.

  • AMM deposit and withdrawal arithmetic, taken from rippled rather than from the formula that circulates (#133). The SDK offered no way to work out what an AMMDeposit would credit before submitting it, so consumers reached for the widely quoted T·(√(1 + b·(1 − f/2)/B) − 1). That formula is the right one with the fee applied loosely, and it is exact wherever there is no fee - which is what makes it hard to catch. At a 1% fee it credits 0.41244·T where the node credits 0.41213·T: out by 0.08%, always in the direction that promises more tokens than arrive.

    • Xrpl.Sugar.AmmMath is static and needs no client: LPTokensForSingleAssetDeposit, LPTokensForSingleAssetWithdraw, LPTokensForProportionalDeposit, AssetsForProportionalDeposit, AssetsForProportionalWithdraw, plus TradingFeeFraction and DiscountedTradingFee with the TradingFeeScale (100 000) and AuctionSlotFeeDiscount (10) constants behind them
    • the single-asset pair are equations 3 and 7 from rippled's AMMHelpers.cpp - lpTokensOut and lpTokensIn - transcribed rather than derived. The two are not symmetric, and the asymmetry is easy to get backwards: lpTokensIn multiplies by the fee where lpTokensOut multiplies by 1 − fee. Swapping them still satisfies the round-trip inequality, which is too loose to notice, so the pair is pinned by the zero-fee identity instead: with no fee the two equations must invert each other exactly, and with the multipliers swapped they miss by a wide margin
    • the auction slot is the trap that makes correct formulas look wrong. Its holder trades at DiscountedFee, a tenth of the pool's fee, and AMMCreate hands the slot to whoever created the pool - so the account most likely to be estimating is the one the pool's fee is wrong for. Estimating at the pool's fee in the integration test was out by 0.23%, three times the error of the approximation this replaces, with every equation right. Read the fee from amm_info's auction slot when the account holds it
    • the swap, and the inverse of each equation. SwapAssetIn/SwapAssetOut are rippled's own, equation (2) in AMMHelpers.h: a payment routed through a pool takes the fee off the input before the curve sees it, which is not the same number as taking it off the output. SingleAssetDepositForLPTokens and SingleAssetWithdrawForLPTokens are equations 4 and 8 - what an AMMDeposit carrying LPTokenOut will cost, and what an AMMWithdraw carrying LPTokenIn returns. Each pair composes to the identity, which is what pins the two derived through a quadratic
    • units are the caller's and nothing converts between them, which is now said out loud because it bites: amm_info reports the XRP side of a pool in drops, so a balance read from it and an amount a caller thinks of in XRP are a million apart, and mixing them reads as a broken formula rather than a unit mistake
    • a fee in the wrong units is refused rather than answered. TradingFee is in units of 1/100 000 and rippled caps it at 1000 (kTradingFeeThreshold, now AmmMath.TradingFeeThreshold); reaching for basis points or whole per cent is out by a factor of ten or a hundred, and the arithmetic notices nothing - at 5000 every intermediate value stays finite and a plausible wrong number comes back. The bound is on the fee itself, so it holds even for an amount of zero
    • what comes back is a bound rather than the exact credit, and the direction is documented. Under fixAMMv1_3 rippled rounds the final multiplication against the caller both ways - lpTokensOut downward, lpTokensIn upward - so a deposit is credited this much or a shade less and a withdrawal costs this much or a shade more. It lands in the last of STAmount's 15 significant digits
    • decimal throughout, and a square root written for it. Math.Sqrt carries 15 significant digits against decimal's 28, and the root is the one step where the formulas need the precision
    • checked against a node, not only against the source: five integration tests put deposits, withdrawals and a payment routed through the pool on the standalone stand and compare the estimate with what the node actually moved. Relative errors from 9.7e-17 (the swap) to 6.2e-14 - the precision the node reports balances at. That is the measurement; what the tests enforce is 1e-9, because the figures compared are differences of two reported balances and pinning them to the last digit would buy brittleness rather than coverage. The swap test also covers the one case the others cannot: an account that does not hold the auction slot, and therefore trades at the pool's own fee. Unit tests can only prove a formula was copied faithfully - a faithful copy of the wrong equation passes all of them
  • nft_info and nft_history, the two Clio commands NFT work needs (#132). Neither had a model, and neither has a substitute on a rippled node.

    • an owner cannot be read out of nft_sell_offers, which is the natural guess. Selling a token does not remove offers for it from the ledger, so offers made by a previous owner keep being returned long after they can no longer be accepted, and the new owner has usually made none - which is exactly the state a token is in right after being bought. Taking the owner from the first offer shows the wrong account
    • field names were taken from Clio's own handlers rather than from documentation, and one of them differs: Clio emits nft_serial, and its own source notes that the docs call it nft_sequence. A test pins the name that arrives on the wire
    • history entries are the same shape account_tx returns, so TransactionSummary reads them - envelopes of API v1 and v2 included - rather than a second type that would have to be kept in step with the same rippled envelopes. Read them through the I interfaces, as with any transaction from a ledger
    • both are Clio-only. A plain rippled node answers unknownCmd, which arrives as an ordinary RippledException carrying that code, so a consumer who has to work against both can recognise it and fall back. There is an integration test for that, against the rippled stand this suite runs on
  • A failed submission arrives as something a caller can act on: TransactionFailedException (#131). SubmitAndWait threw a bare RippleException whose only content was a sentence, so telling tecINSUFFICIENT_PAYMENT from tecEXPIRED meant reading the text - and the classes of code mean entirely different things: tem is a malformed request to fix, tec was applied with the fee taken, ter may work later. The hash was not available at all, and the hash is exactly what is wanted after a tec: the transaction is in a ledger, and showing it is the first thing anyone does.

    • the new type carries EngineResult, Hash, Result (the validated transaction and its metadata) and ReachedLedger
    • nothing breaks. It derives from RippleException and the message is byte-for-byte what it was - deliberately not improved while the code was open. Four integration tests in this repository assert that text word for word and needed no change, which is the same claim made from the other side
    • ReachedLedger is read from the result code, not from whether Result happens to be present. The same failure is reported at one of two moments depending on whether the ledger closed before the first poll - after validation, with metadata, or earlier from the node's provisional answer, when only the code and the hash exist. A tec was applied either way, and which moment won a race is not something a caller should have to reason about. Result can therefore be null while ReachedLedger is true; Hash is present in both
  • A transaction declared as its interface serializes like the transaction it is (#127). System.Text.Json picks a converter by the declared type, and [JsonConverter] sat on the abstract class rather than on ITransactionRequest. A variable typed as the interface was therefore written as the interface: every field of the actual transaction type gone - a payment with no Amount and no Destination - with no exception and no warning.

    • it worked as long as everything went through XrplJsonOptions.Default, whose converter list makes up for the missing attribute. Someone serializing with their own JsonSerializerOptions got the empty form, and found out at a node refusal or, worse, at a transaction that succeeded meaning something else
    • two more symptoms went with it and are cured by the same line: "TransactionType":16 instead of "Payment", because that converter is on the class property too, and SigningPublicKey/TransactionSignature on the wire instead of SigningPubKey/TxnSignature, because the [JsonPropertyName] attributes are on the class as well
    • declared on ITransactionRequest and ITransactionResponse in addition to the classes. Nothing else changes: the converter already dispatches on value.GetType(), so what it writes is what the concrete type would have written. Interfaces are exactly what one declares where the transaction type is the caller's choice - factories, submission pipelines, wallet wrappers
  • Four places where the model said something that was not so (#128, #129, #134, #135). None of them failed loudly: an inverted predicate returns a bool, a field the protocol does not have serializes fine, a pattern match on the wrong half of a type pair compiles and finds nothing.

    • Currency.IsMPTToken answered the exact opposite (#128) - the negation was missing, so every amount that is not a multi-purpose token was reported as one, and the only kind it can be true of was reported as not. Nothing in the SDK calls it, which is why nothing showed it; the first consumer to branch on it would have taken the wrong branch every time, where the branch decides how to render an amount and how to add it up
    • NFTokenAcceptOffer no longer declares NFTokenID (#129). rippled's transactions.macro gives that transaction exactly three of its own fields - NFTokenBuyOffer, NFTokenSellOffer, NFTokenBrokerFee - and the SDK's own TxFormat already carried the field commented out with "no need this field". The property serialized like any other, so the type suggested it, IntelliSense offered it, and whoever filled it in got a node refusal with no hint from the types. Removed rather than obsoleted, the same clean break as Models.Path above
    • ValidateNFTokenAcceptOffer now refuses the same offer on both sides (#134). rippled compares each offer's owner against the submitter separately - the two blocks in its preclaim read like a choice between direct and brokered mode and are not one - so naming one offer twice makes one of those comparisons the account against itself: tecCANT_ACCEPT_OWN_NFTOKEN_OFFER, in a ledger, fee taken. One comparison catches it with nothing to ask the node. The same doc comment now also records that brokered mode needs three distinct accounts, which the C++ reads as saying the opposite. Note that this validator is opt-in: unlike the memo rules above, Validation.Validate runs only when a consumer calls it
    • Reading history is documented (#135). What comes back from account_tx, tx or SubmitAndWait is the response half of a type pair, so summary.Transaction is NFTokenCreateOffer compiles, warns about nothing and never matches - which looks exactly like the response having failed to parse. Request and response types come in pairs that share an I interface; match on that. Written on TransactionSummary.Transaction, where the confusion happens, and in the README. The advice was measured before it was given, and a test holds it to that: it applies to 77 pairs and not to five - the ConfidentialMPT transactions carry no interface at all, on either half, so there is nothing to match on there. Those five are named in the test rather than hidden by it, which is also what makes a sixth such type fail here instead of in a consumer's silent non-match
  • A memo a node will refuse is refused before signing (#119). rippled caps the serialized Memos array at 1024 bytes in passesLocalChecksisMemoOkay. That is a local check: the transaction is not relayed, reaches no ledger and costs no fee - the consumer simply finds out, after building, autofilling and signing it, that the node will not take it, and the answer names no field. The SDK checked the array's shape and never its size.

    • MemoRules.Validate is called from every public entry that signs - Sign, SignAsBatchPart, SignAsSponsor, SignAsLoanCounterparty - and deliberately not from Validation.Validate, which production code calls nowhere and which would have made this a rule nobody runs. Guarding Sign alone was the first attempt and was not enough: the SDK's own multi-batch submission signs batch parts directly, so that path went unchecked
    • measured the way the node measures it, by serializing the array through the codec rather than counting bytes by hand: one object start marker, the fields with their length prefixes, one end marker, and no array markers. In practice 1019 bytes of MemoData fit in a memo carrying nothing else. The limit is on the array, so splitting the content across several memos does not raise it - the exception says so, because trying that is the obvious next move
    • MemoType and MemoFormat must also decode to characters RFC 3986 allows in a URL; the exception names the offending byte. MemoData is exempt, which is the whole point of it
    • two of the node's five memo rules are not repeated here: a member other than MemoType/MemoData/MemoFormat inside a Memo, and a value that is not hex, are already refused by the codec - and refused better, since it names the member at fault. An earlier draft of this check reported them first and in poorer words, which two existing tests caught
    • verified against a real node, both directions: on the standalone stand a memo of exactly the limit reaches a ledger, and one byte over is refused with "The memo exceeds the maximum allowed size." A constant read off a source file can be wrong by being too strict as easily as too loose, and only the node can settle it
  • Giving up on a connection reports giving up, not a cancellation nobody asked for (#122). Connect() is two operations, not one — the connection, and the server_info that SetNetworkId sends straight after it. The socket really does open for a moment before a failing OnConnected handler brings it down, so the wait can return successfully and the caller can already be inside that second operation when the client gives up. The give-up path went through Disconnect(), which rejects everything in flight with OperationCanceledException — right for a close the caller asked for, wrong for a failure. A consumer that catches cancellation to tell "someone cancelled this" from "this failed" was told the first when the second had happened.

    • the give-up path now rejects in-flight requests with NotConnectedException before disconnecting, so the caller hears the same thing whichever of the two operations they were in
    • the same wrong answer came out of the path next door, and there it was worse. A handler that fails once and works on the next attempt is precisely what the reconnect path is for - but the teardown in between rejected the caller's in-flight server_info too, so Connect() threw while the client went on to connect. Measured: the caller got OperationCanceledException and IsConnected() was true three seconds later. Connect() now waits for the client's own recovery and asks again, so a failure it recovered from does not reach the caller at all. Asking "is it connected right now?" instead would not have worked - at that moment the socket has just been torn down and the answer is no, however well the recovery is going
    • that wait is also what keeps the terminal case honest: it ends when the connection is back, or throws NotConnectedException when the client gave up
    • the flake is now a test. This failed on CI about every other run and never once in 37 local runs, which is why it sat open with the mechanism unproven. Holding the mock's server_info back with a delay puts the request in flight for certain: the reproduction failed three times out of three before the fix and passes after it
  • A session that ends now says so, whatever ended it: OnSessionEnded (#123). Subscriptions live on the node against one connection, so when the connection goes they go with it and the consumer has to resubscribe. Knowing when was the problem: a session could end in more than one way, and not every one of them said so.

    • ChangeServer announced nothing at all. It marks the old session retiring, which makes the socket's own close callback return early by design, and the only notification it sends is a Connecting status — the same one a first connection sends. The client went on reporting Connected, the button still said "subscribed", and the stream was dead for good. Reproduced three times in the Blazor demo in this repository (mainnet → testnet, testnet → devnet, mainnet → testnet): after the switch, 0 transactions and 0 ledgers, while a manual resubscribe brought the stream straight back — so the connection was fine and it was the subscription that had gone
    • the fast-reconnect path — a ping timeout or a network drop — sent only a RestoringConnection status, which says the connection is being rebuilt, not that everything bound to the old one is gone. A consumer had to infer the second from the first
    • a user Disconnect() was not reliably announced either — and neither was OnDisconnect. The receive loop had one way out that reported nothing: its own while condition. A message handler runs inline on that thread and the request continuation it completes runs inline in turn, so a caller that disconnects right after the response that woke it does so inside the loop. The loop comes back round, finds the cancellation already set, and leaves silently. Cancelling a parked receive throws and is reported normally — which is why this looked correct against a slow peer and vanished against a fast one. That exit now reports the close like every other, so OnDisconnect fires on this path too and the session end rides along with it
    • what was covered was a socket closing by itself, by OnDisconnect - which is exactly why a consumer that resubscribed on disconnect looked correct and still lost the stream on the paths above
    • the fix announces the end explicitly on the two deliberate retirement paths, which no callback can speak for, and leaves the rest to the socket's close callback — now that it always runs. A guard on the session object keeps it to one announcement per session
    • OnDisconnect is unchanged and still means what it meant — a socket closed. OnSessionEnded is the one thing to subscribe to in order to know a resubscribe is due, and carries a SessionEndReason (ServerChanged, ConnectionLost, UserDisconnected) so a consumer can tell a switch from a failure from its own doing
    • it does not fire for a connection attempt that never succeeded: there was no session, and so no subscription, to lose
    • this was never a regression. The same scenario reproduces identically on origin/release (Xrpl 10.12.0.0), before all of the stream work in this release — same Connected, same missing restoration, 33 seconds of silence. The demo did not change between the two, and the connection.cs diff touches none of the retirement path. The defect was simply silent, and making the stream observable is what made it visible
    • not automatic resubscription (#104), which is a larger question and would need the SDK to hold subscription state. This gives the consumer the signal and leaves the decision with them
  • Models.Path is a path step, and now says so: Xrpl.Models.Common.PathStep (#117). The type describes one step of one path, not a path, and the old name cost three separate things.

    • it collided with System.IO.Path. The proof was already in this repository: TestUResponseFidelity.cs was the one test file importing both System.IO and Xrpl.Models.Methods, and it had to write System.IO.Path.Combine in three places while its neighbours wrote Path.Combine. Those three qualifications are gone in this change, which is the check that the collision is gone with them. Consumers paid more: with ImplicitUsings on, a single using Xrpl.Models.Methods; was enough to turn any Path.Combine in the file into CS0104
    • it collided with Xrpl.BinaryCodec.Types.Path, which is a whole path — so one name meant a container in one half of the SDK and its element in the other. No file imported both, so nothing had failed yet; the codec keeps its Path, where the name is right
    • everything around it already said step: PathStepType, Validation.IsPathStep, TestUPathStep, and xrpl.js, where this is PathStep and Path = PathStep[]. List<List<Path>> read as a list of lists of paths while meaning a list of paths
    • the wire format does not change. Only the C# name moves; the [JsonPropertyName] on account, currency, issuer, mpt_issuance_id and type are untouched, so serialization and signing are identical
    • no [Obsolete] bridge, because there cannot be one: [Obsolete] class Path : PathStep {} would not help, since generics are invariant and List<List<Path>> still would not convert. It would add a type to the public surface and compile nothing that did not compile anyway
    • two files stopped needing using Xrpl.Models.Methods; altogether once the type moved - Payment.cs and TestUPathStep.cs - so they no longer drag in the namespace that caused the collision in the first place. Both using directives are deleted rather than left as decoration
    • migration: replace Path with PathStep and add using Xrpl.Models.Common;, or put using Path = Xrpl.Models.Common.PathStep; at the top of the file for now
  • Xrpl.Models.Utils.Index is now ModelUtils (#117, the same defect one layer over). Index was a calque of the barrel file utils/index.ts it was ported from, and it collides with System.Index, which is in scope in every file whether anyone asked for it or not. Payment.cs carried using Index = Xrpl.Models.Utils.Index; — an alias that existed for no other reason than to work around that collision, and is now deleted. The class also finally matches ModelUtils.cs, the file it always lived in

  • Xrpl goes to 11.0.0.0 (from 10.12.0.0), the major this release has been heading for since the first breaking change in it. Xrpl.BinaryCodec is already at 11.0.0.0; Xrpl.AddressCodec and Xrpl.Keypairs stay at 10.9.0.0, untouched since the last release

  • Fourteen response fields the node sends now have typed properties (#106). Thirteen was the count in the report; measuring found one more, and the arithmetic below is ten plus one plus two plus one. Unknown-field capture made the loss visible instead of silent; these were the ones it found. Capture is the safety net, declaring them is the fix, and a field counts as done only when it is a declared property and gone from UnknownFields - either half alone can pass while the other fails.

    • ServerInfo.Info gains ten, not the seven the report listed. Measuring against a node rather than working from the list found three more - git, node_size and validator_list. The test asserts the whole capture is empty rather than a list of names, precisely so it cannot miss what nobody thought of
    • the types came from a node too, not from documentation. server_state_duration_us is a string in server_info while the same field is a number in server_state; initial_sync_duration_us, jq_trans_overflow, peer_disconnects, peer_disconnects_resources and time are all strings. ports is a list of {port, protocol[]}, and git and validator_list are objects, so three small types come with them
    • AccountLines.Validated - the one sibling result model that never declared it, though rippled writes it through lookupLedger unconditionally. AccountInfo, AccountObjects, AccountNFTs, AccountCurrencies and NoRippleCheck have always had it
    • LOLedger.ClosedLedger and LOLedger.OpenLedger - a ledger call naming no ledger answers with two whole structures rather than one. Not to be confused with BaseLedgerEntity.Closed, which is the boolean inside a ledger: the same word for two different things is why these went unnoticed
    • LOEscrow.Flags, replacing a //todo that reasoned a field which is always zero need not be modelled. That confuses "always zero" with "never sent" - it arrives on every deleted Escrow node in transaction metadata. A plain number rather than an enum, because no lsfEscrow* flag is defined and inventing an empty enum would claim a vocabulary that does not exist
  • An unknown member one level down no longer disappears from what gets signed (#107). StObject.FromJson refused members this codec does not know - but only at the top level. Nested objects reached it through a dispatch table whose delegate signature carries no strictness flag, so they went through the lenient overload and the member was dropped without a word.

    • the cost was show-one-sign-another, arriving from the outgoing side: a caller is shown a transaction carrying a member - a typo in a field name, or a field from an amendment newer than this SDK's definitions.json - signs it, submits it, and it lands in the ledger without that member. The top level failed loudly; one level down it did not
    • rippled does the opposite. STParsedJSON::parseObject recurses and answers unknownField at every level, so such a transaction does not parse at all
    • the Batch case was worse than reported. BatchNormalizer.ComputeInnerTxId parsed leniently too, and that id is what the outer Batch signature commits to - so the signature fixed an inner transaction other than the one the caller was shown. Strict there means strict without the signing filter: an id covers the whole transaction, so dropping non-signing fields would hash something else again
    • Xrpl.BinaryCodec goes to 11.0.0.0 (from 10.11.0.0). The package changes behaviour for input that used to succeed and gains a public member, so it takes the major with Xrpl rather than trailing behind. Xrpl.AddressCodec and Xrpl.Keypairs are untouched since the last release and stay where they are - which is correct, not an oversight: they are consumed by ProjectReference, so a newer Xrpl keeps depending on the versions already published
    • new on Xrpl.BinaryCodec: StObject.FromJsonStrict(JsonNode) - the strict parse without the signing filter. Public because it is needed from the Xrpl package and the two assemblies share no InternalsVisibleTo
    • strictness and the signing filter are now separate. One flag used to mean both "refuse unknown members" and "drop non-signing fields"; only the first recurses. FilterIsSigning still applies to the top level alone, because filtering nested objects would change what gets signed
    • Encode stays lenient about unknown fields, deliberately - members of an STObject, at any depth. Of its twelve call sites, four run a transaction through the codec to answer a question about it - IsSigned, IsAccountDelete, GetLastLedgerSequence, ValidateTransactionEquivalence - and HashSignedTx hashes transactions that came from the node. Making that strict would turn a predicate into an exception and would fail on any response carrying a field newer than this SDK's definitions.json, which is the forward compatibility the raw-JSON work exists to keep
    • it is not lenient about unknown members of the self-parsing object-valued types, and never fully was: Issue's two standard forms have counted their members since long before this change, so Encode already threw on a malformed Issue. The line is between an open shape and a closed one. A transaction gains fields with every amendment, and dropping one still leaves a valid transaction minus a field. Issue, XChainBridge and a path step have fixed shapes that cannot serialize a member outside them at all - dropping one does not yield the same structure minus a member, it yields a different structure, and any blob or hash over it is simply wrong
    • object-valued fields that are not StObject were a second hole, found in review. Issue in its MPT form, XChainBridgeType and the steps of a PathSet parse themselves rather than going through the recursion, and each read past members it did not know. Issue's two other forms have always counted theirs, so the MPT form was the odd one out; the other two now count as well
    • type and type_hex are named as members a path step may carry, because refusing them would break the ordinary flow: ripple_path_find answers with a type on every step, this SDK declares it on Path and emits it back out, so a path taken from a response and put into a payment carries it. The byte is synthesised from which of account, currency and issuer are present, making the member redundant rather than unknown. A test pins that, and it is the only thing between this change and a broken path-finding flow
    • migration: a caller passing a transaction with a member this SDK does not know used to get a silently reduced blob and now gets an exception. If the member is real and new, update the SDK; if it is a typo, this is the error that was missing
  • failHard removed from GetSignedTx. The parameter was declared and never read: the method autofills, signs and encodes, and nothing there talks to the network, so there is no submission to fail hard about. Submit even passed an explicit failHard: false into it while keeping its own value for the actual submit - the author already knew it meant nothing. Callers of Submit and SubmitAndWait are unaffected; a direct caller of GetSignedTx drops the argument. Positional callers get a compile error rather than a silent rebind, since the next parameter is an XrplWallet

  • The stream queue now exists before consumer code can subscribe (#113). StartMessageProcessor ran at the very end of OnceOpen, after ResolveAllAwaiting() and after the OnConnected callback. Subscribing from that callback is the ordinary pattern - it is what the wallet consuming this SDK does - and the node can answer the subscription before the handler returns, so the first frames found no channel and took the fallback: outside StreamMessageQueueCapacity, uncounted by DroppedStreamMessages, dispatched concurrently rather than one at a time. The events most likely to arrive out of order were precisely the first ones after connecting.

    • the blocker was an unrelated coupling, and that is the real fix. StartPingTimer begins with StopPingTimerSync, which also called StopMessageProcessor, so simply starting the processor earlier had it torn down again moments later - measurably: TestUDroppedStreamMessagesCountsWhatTheConsumerNeverSaw went from 10 evictions to 0. Stopping a ping timer has no business stopping the message queue; the two lifecycles are now separate
    • the processor is stopped explicitly where a connection genuinely ends - Disconnect, DisconnectAndWaitAsync, OnceClose, OnConnectHandlerFailedAsync, ChangeServer and RetireCurrentSessionAndReconnectAsync - rather than by side effect of a timer
    • the fallback path stays, and that was measured rather than assumed: declaring it unreachable fails 6 tests. It still serves OnMessage on a client that never connected, anything arriving after the processor is stopped, and a write the channel refuses because its writer is already completed
    • FallbackDispatchedStreamMessages, added alongside, is what makes this checkable from outside. It counts cumulatively and the fallback stays legitimate outside a connection, so the thing to watch is not an absolute zero but a zero delta: the counter should not move while a connection is being established
  • The model stopped inventing values it was never sent, and stopped losing ones it does not know (breaking) — the second half of the raw-response work. Re-serializing a typed response used to differ from what arrived in both directions. Measured on live mainnet responses at api_version = 2: a ten-entry account_tx gained 156 fabricated members and dropped 28. After this level the same capture gains 4 and drops 15, and every fabricated member left is the Amount/DeliverMax rename, which is the next level.

    • Scope came from the protocol, not from a hand count. The repository already vendors rippled's ledger_entries.macro and parses which fields are Required, Optional or Default. A new conformance test, TestUNullabilityConformance, reads it: a field the protocol allows to be absent must map to a property that can express absence. It found 9 direct violationsAMM.TradingFee, FeeSettings.ReferenceFeeUnits/ReserveBase/ReserveIncrement, LedgerHashes.FirstLedgerSequence/LastLedgerSequence, MPTokenIssuance.AssetScale, PayChannel.SourceTag/DestinationTag
    • A second, broader rule applies to the same models. rippled's requirement flag describes the ledger object, but these models double as the contents of PreviousFields, FinalFields and NewFields — and PreviousFields carries only the members a transaction changed, so there even a Required field can be missing. Hence every value-typed property of a ledger-entry model is now nullable. Counted as the conformance test counts them — pairs of (model, property), which is what the defect is measured in — that is 80 pairs; as a diff it is 50 property declarations across 23 files, the difference being LedgerEntryType on the shared base, which one edit covers for all 31 models. On top of that, 21 properties on transaction models and AccountInfo.LedgerIndex/LedgerCurrentIndex
    • LedgerEntryType became nullable too, and the constructors that stamped it were removed along with the unconditional stamp in LedgerObjectConverter. Without both, the property stayed non-null by construction and the change would have been decorative — round-trip on a ModifiedNode went from 6 fabricated members to 2 to 0
    • LONFTokenPage.PreviousTxnLgrSeq was long while definitions.json declares the field UInt32 and every other model uses uint. Corrected to uint? — unrelated to nullability, found while surveying
    • [JsonExtensionData] on BaseLedgerEntry and BaseTransactionResponse. A member no model knows — a field arriving with a new amendment — used to vanish silently. It now lands in UnknownFields and survives a round trip. Verified that the hand-written converters (LOConverter, ModifiedNodeConverter, TransactionResponseConverter and the rest) do not swallow it: they parse only the envelope and delegate the fields to the reflection path, which honours the attribute
    • BaseResponse.Id and ErrorResponse.Request left object — the known remainder of the first level. Both were filled with a JsonElement whose pooled array is never returned; measured at 3 672 B retained per envelope carrying an id against 217 B without one, on every response. Both now record bounds, exposed as RawId and RawRequest, and the request id is parsed straight from the bytes with Utf8Parser instead of being formatted into a string first. The retention budget tightened from 8 192 to 6 144 bytes

    Migrating. The common case:

    // was
    uint sequence = accountRoot.Sequence;
    
    // now — decide what absence means before you write anything
    if (accountRoot.Sequence is not { } sequence)
    {
        throw new InvalidOperationException("account_info returned no Sequence");
    }

    Reach for ?? 0 only where zero is a legitimate value of the field, not merely a way to make the code compile. Substituting zero elsewhere puts back exactly the defect this change removes — it just moves the lie out of serialization and into your logic. Two places inside the SDK showed why this matters, both found while migrating and both fixed with an explicit failure instead of a default:

    • a lifted collected < SignerQuorum returns false when the quorum is absent, so the "insufficient signatures" check silently stopped firing
    • a lifted (Flags & lsfAccepted) != 0 returns true when Flags is absent — the opposite direction from < — so a credential that was never accepted read as accepted. That one was a fail-open in a permissioned-domain access check

    If a value is genuinely required, fail loudly; if absence is legitimate, branch on it. Do not reach for ?? 0 by reflex.

  • The accuracy above is now held by a test, not by hand measurement. Every figure quoted in this section came from a throwaway console project built, run and deleted. That meant any of these defects could come back unnoticed — which is exactly how LOAccountRoot went without WalletLocator/WalletSize until a manual pass, and how sfLEVersion had to be caught by a protocol-watch notification instead of a red test.

    • Tests/Xrpl.Tests/Fixtures/Responses/ holds seven live mainnet responses — six captured at api_version: 2 and one at v1, which is what catches the case where the node sends both Amount and DeliverMax for the same value — including one with binary: true, one carrying warning: "load", and a ten-entry account_tx with the metadata that started all of this. A README.md beside them records where and when they came from, because updating them has to be a deliberate act
    • TestUResponseFidelity deserializes each one, serializes it back and compares the trees. Fabricated members must be zero, with no exceptions — a fabrication is a claim the node said something it did not. Dropped members are checked against an explicit list where each entry carries its reason; anything not on that list fails the test. The list turns "we think we know what we lose" into a checked fact
    • the test is proven to fail, not merely to pass: reverting a single property to non-nullable produced 24 fabrications across three fixtures, and removing [JsonExtensionData] produced 3 drops. A test that stays green when a defect is reintroduced guards nothing
    • two members remain knowingly dropped and are recorded as such: $.status, which lives outside result in the WebSocket envelope and reaches callers through XrplResponse<T>.Status; and $.warning inside account_objects, found during this work and not yet closed
  • Level 3: API v1 and v2 are told apart, and the fields neither model had a place for are filled in. The last class of defect the raw-response effort set out to remove: a field the node sent under one valid protocol name coming back under a different one, and a client method that silently spoke a different API version than the one it was configured for. Measured on the same live ten-entry account_tx capture used throughout this section: 156 fabricated members before any of this work, 0 after it — the last 4 (the Amount/DeliverMax rename, called out as "the next level" above) are gone along with the rest.

    • PaymentResponse no longer substitutes Amount for DeliverMax on the way back out (breaking) — the worst class of the three remaining defects, and the reason it got called out separately from the rest of the nullability work: not a lost field, but a substitution for a different, only superficially-equivalent protocol field name. A wallet's reconciliation screen — the one thing that exists so a person can check what they are about to sign — would show a field the node never sent. PaymentResponse now remembers which name a value arrived under — two independent presence flags, one per name, set by whichever of the two mirrored JSON properties fired on read — and serializes it back under that same name. Two flags rather than one because API v1 sends both names for the same amount: a single flag would be overwritten by whichever setter ran second, and one of the two names would be dropped. So: Amount alone comes back as Amount, DeliverMax alone as DeliverMax, both as both, and an object built in code as Amount. Amount itself stays the one public property a caller reads — it is excluded from JSON directly, so nothing about reading it changed; only which wire name the value goes back out under did.

      • Payment (the request/signing class) is deliberately left asymmetric. It still always writes Amount, regardless of which name a value came in under — this is not an oversight, and the reasoning is now recorded on the property itself (Payment.cs, the DeliverMax alias), not just here. Checked directly against Base/Xrpl.BinaryCodec/Enums/definitions.json: Amount, DeliverMin and SendMax all have an entry there; DeliverMax does not, because it is a JSON API v2 presentation-layer rename with no binary field code of its own. Payment.ToJson() feeds XrplWallet.Sign()EncodeForSigning, which looks fields up by name in definitions.json — an object that re-emitted "DeliverMax" would have the codec silently fail to find the field and drop the amount from the signed blob, the worst possible outcome, a transaction signed with no amount in it. PaymentResponse is read-only display data and never reaches the codec, so it alone is safe to make symmetric. An existing test, TestPaymentDoesNotSerializeDeliverMax, already pinned the asymmetric contract before this change gave it a documented reason
    • Tx() renamed to TxV1() (breaking) — it pinned request.ApiVersion = 1 regardless of ClientOptions.ApiVersion (which defaults to 2), the one place in the SDK where the choice of method — not a setting — silently decided the protocol version. It cannot be made to honor the client setting instead: TransactionResponse has no field for API v2's tx_json, so handing it a v2 payload would lose the transaction wholesale, not just a field name, the way PaymentResponse above does. Since the behavior has to stay, the name now says what it does:

      // was — silently API v1, independently of ClientOptions.ApiVersion
      TransactionResponse tx = (await client.Tx(request)).Result;
      
      // now — the method name carries the protocol version instead of hiding it
      TransactionResponse tx = (await client.TxV1(request)).Result;

      TxV2(...), unchanged, sits beside it for the tx_json/meta shape. No [Obsolete] shim, consistent with this repository's major-version policy. Six integration test files and one hand-written IXrplClient test double were the only callers; nothing inside the SDK itself called Tx()

    • close_time_iso, ctid, status, meta_blob, tx_blob — the remaining unmodeled fields, added where rippled actually puts them, not where it would be convenient. close_time_iso and ctid now live on IBaseTransactionResponse/BaseTransactionResponse, reached by every transaction response type. ctid also exists separately on TransactionSummary (Models/Methods/AccountTransactions.cs), and both are real: the singular tx method reports ctid as a sibling of tx_json, which is what TransactionSummary.Ctid reads, while account_tx nests ctid inside tx_json itself, which lands on the transaction's own Ctid instead — two different positions in the protocol, not one field modeled twice by mistake. status was closed by giving XrplResponse<T> its own Status member: it sits beside result in the envelope, not inside it, so it was never reachable through Raw. meta_blob/tx_blob cover API v2 with binary: true, where rippled sends the transaction and its metadata as top-level hex siblings instead of the usual tx_json/meta — before these existed, that response shape lost its body wholesale (measured: 2246 B in, 195 B out)

    • FromStringDateTimeConverter silently returned null for every timestamp rippled actually sends. Not a missing-field defect like the rest of this level — the property already existed — but a parser that never matched real input, which reads the same as a missing field from the outside. It parsed with the exact format "yyyy-MM-ddTHH:mm:sszzz", which only accepts a numeric UTC offset (+00:00); rippled sends close_time_iso with a literal Z suffix ("2013-03-12T23:16:50Z"), which zzz rejects. TryParseExact returned false on every real capture, so close_time_iso came back null regardless of whether a property was there to receive it. Fixed by switching to the "K" custom specifier, which accepts both forms, plus DateTimeStyles.AdjustToUniversal so a numeric-offset value still normalizes to UTC instead of being left in local time

    • TestUBaseTransactionResponseFields and TestUPaymentDeliverMaxRoundTrip pin all of the above against real captures — a live tx response, a live account_tx entry reshaped to the API v1 wire form the way rippled genuinely flattens it, and a live binary: true capture — round-tripping through deserialize → serialize and asserting the wire name that comes back, not just that a value is present

  • Methods now return XrplResponse<T>: the typed result and, beside it, the bytes the node sent (breaking) — the point of the whole effort. A consumer could not get what a node actually said, only the model, and re-serializing that model differs from the original in both directions. Measured on live mainnet responses at api_version = 2: close_time_iso, ctid, tx_json.DeliverMax and meta_blob are dropped; PreviousFields.Flags = 0, LedgerEntryType and — on a Payment — TransactionType = "AccountSet" are invented, 156 fabricated members on a ten-entry account_tx alone. For a wallet rendering a transaction so a person can check what they are signing, that is false precision.

    • XrplResponse<T> carries Result (the projection), Raw (the result member exactly as sent), and the envelope the client used to unwrap and discard: ApiVersion, Warning, Warnings, Forwarded. Warnings is never null
    • no implicit conversion to T, deliberately. Measured against this codebase it would carry fewer than half the call sites — 248 with an explicit type against 273 using var, which break either way — leaving a partial compatibility harder to migrate than a clean break, and hiding that Raw exists at all
    • RawJson gained Deserialize<T>(), ToJsonElement() and HasTopLevelProperty(), so a consumer does not reach for JsonSerializer with options of their own — the XRPL models depend on the converters in XrplJsonOptions.Default, and bare options silently produce a different object
    • the envelope is paired with its frame through AttachFrame(byte[]) rather than a settable property, so the bounds are checked once where the frame and the recorded slice meet instead of lazily inside every read of RawResult. The method is internal on purpose — see the note on Stream overloads below. (No public member was removed here: the settable form existed only between commits of this release)
    • warning — the literal "load", rippled's rate-limit signal — reaches the caller for the first time. It is not reachable through Raw either: that is the result member, while warning lives in the envelope around it

    Migrating. There is no [Obsolete] shim, so here is the move:

    // was
    AccountInfo info = await client.AccountInfo(request);
    
    // now
    AccountInfo info = (await client.AccountInfo(request)).Result;
    
    // and what the work was for
    XrplResponse<AccountInfo> response = await client.AccountInfo(request);
    string asTheNodeSentIt = response.Raw.ToString();
    • the tx method pins api_version = 1 regardless of ClientOptions.ApiVersion, so its Raw is the honest text of a v1 response. A caller on API v2 — a wallet checking what it is about to sign — wants TxV2(...), which maps tx_json and meta as siblings the way v2 sends them. This is resolved further down in this same release: the method is renamed TxV1() so the version is stated in the name rather than hidden in the body
    • breaking: 43 members change return type — 41 typed methods, Request(Dictionary<string, object>), and GRequest<T, R> itself. Connection.Request and Connection.GRequest<T, R> change with them, and RequestManager's XrplRequest.Promise / XrplGRequest.Promise now resolve to ResolvedResponse rather than the value directly. ResolvedResponse and the XrplResponse.From<T> unpacker are public for exactly that reason: RequestManager is public, and a caller working at that level has to be able to name what it gets back. From<T> has a second overload that takes a ResolvedResponse directly, for a caller that already has one off an awaited Promise — the mismatch the object overload can only catch at run time, as an XrplException, becomes a compile error through this one
    • not affected: the sugar methods — GetXrpBalance, GetLedgerIndex, SubmitAndWait, GetOrderBook, Submit(ITransactionRequest, …) — keep their existing return types. They were already handing back the typed result before this change and still do; nothing about them moved
    • Request(...) and GRequest<T, R>(...) are no longer async methods — they delegate straight to Connection's versions of themselves. An argument-validation exception they raise now leaves the call synchronously, before a Task is even returned, rather than surfacing when the returned task is awaited
    • XrplResponse<T> gained Deconstruct(out T, out RawJson), so var (result, raw) = await client.AccountInfo(request) works — the one-line fix for the call sites that broke hardest on this change, the ones using var — and HasNextPage, reading the same marker signal as the BaseResponse.HasNextPage() extension — which is still there and was fixed in this same release — but reachable from the type a caller of the client's own methods actually holds
    • TestUXrplResponse proves the feature end to end over a socket: a scripted response with irregular whitespace and a member no model knows comes back byte for byte in Raw. That assertion originally had a second half — the same member proved absent from the re-serialized Result — which extension-data capture later made false; it now proves the opposite, that the member reaches the caller on both sides, while Raw remains the only byte-exact one. The envelope is asserted on both the typed and the untyped path
    • costs nothing measurable: XrplResponse<T> is a 64-byte readonly struct for a reference-typed T (Unsafe.SizeOf; 56 bytes for a value-typed T, and none of the 43 methods are parameterized with one, so 64 is the figure that applies in practice — it grew by 8 when Warning and Status were added to the envelope later in this release) that lands directly in the async method's result field, and all three allocation budgets are unchanged after the switch — 1.89x on the JsonElement path, 5.57x typed, 2.18x over the socket
  • The result member was parsed twice and its intermediate document was never given back (breaking) — BaseResponse.Result was typed object, which System.Text.Json fills with a self-contained JsonElement. Building it costs a JsonDocument.ParseValue, which rents its backing array from ArrayPool and never returns it: 65 536 bytes rented for a 36 691-byte response, held for a subtree that was then deserialized a second time to reach the requested type. The envelope now records where result sits instead of materializing it, and the requested type is cut straight from those bytes — one parse, no intermediate document, nothing left unreturned.

    • JsonSlice (byte offset + length) and JsonSliceConverter, which reaches the bounds through Utf8JsonReader.TokenStartIndex / Skip() / BytesConsumed without materializing the subtree. Write throws NotSupportedException: a response envelope describes what a node sent, and re-emitting it from the parsed form is exactly the plausible-but-different document this work exists to remove

    • RawJson — a window onto the frame rather than a copy of it. The frame is the exact-sized new byte[] the receive loop already allocates per message, so holding the window costs nothing beyond keeping that array alive. UTF-16 is never stored; ToString() builds it on demand. ToArray() is the documented way to outlive the response without pinning the whole frame

    • RequestManager.HandleResponse takes the frame and pairs it with the bounds. The string overload now encodes to UTF-8 first, and that is required, not incidental: on Deserialize(string) System.Text.Json transcodes into a buffer of its own, so the bounds would be relative to that buffer, unpairable, and the result would be lost on every response. Measured, the explicit array is still cheaper than what it replaced — 94 016 → 54 200 B per message — because the old path paid the same transcode plus the unreturned rental

    • breaking: BaseResponse.Result is gone, replaced by RawResult — the bytes as sent. The bounds themselves are an implementation detail and stay internal. RequestManager.HandleResponse(ReadOnlySpan<byte>) is gone, replaced by HandleResponse(byte[]) — a span cannot be stored, and the frame must now outlive the call. As a consequence HandleResponse(null) no longer compiles: it is ambiguous between the string and byte[] overloads. No [Obsolete] grace period, consistent with Path.TypeHex in 10.11.0.0

    • ownership moved with the signature. The returned response keeps the array and cuts RawResult from it, so a caller must not reuse or mutate the buffer it passed — a pooled or ring buffer would silently rewrite a response already handed out. TestUResponseAliasesTheFrameItWasGiven pins this

    • the frame reference is deliberately internal. The bounds are only meaningful for a reader that covered one contiguous buffer, which the Stream overloads do not — there the offsets come out relative to the chunk, wrong and with no exception to say so (measured: offset 40 012 instead of 40 019 on a 40 KB payload). Keeping it unreachable from outside disarms that path by construction: an envelope a consumer deserializes from a stream has no frame, so RawResult comes back empty rather than pointing at bytes nobody checked

    • migrating off Result. There is no [Obsolete] shim, so here is the whole move. Reading a member:

      // was
      JsonElement result = (JsonElement)response.Result;
      string marker = result.GetProperty("marker").GetString();
      
      // now — parse the bytes the node sent
      using JsonDocument document = JsonDocument.Parse(response.RawResult.Span);
      string marker = document.RootElement.GetProperty("marker").GetString();

      Or, for the whole member as a value: JsonSerializer.Deserialize<T>(response.RawResult.Span, XrplJsonOptions.Default). response.RawResult.ToString() gives the text verbatim, and allocates a UTF-16 copy each call — hold the result if you need it twice

    • RawResult is empty on any envelope you deserialize yourself. It is populated only by RequestManager on the request path. Stream messages, LedgerStreamResponse and friends, and anything you hand to JsonSerializer.Deserialize<BaseResponse> come back with no frame and therefore an empty RawResult — by design, see the internal Frame note above. None of those paths read result before, either

    • behaviour shift on a hand-assembled response. RequestManager.Resolve used to fall back to JsonSerializer.Deserialize(result.ToString(), type) for a BaseResponse that was built rather than parsed off the wire. Such a response now has no frame, so DeserializeResult substitutes {} and the promise completes successfully with a defaulted object instead of carrying the assembled values. Unreachable through the client — the only caller of Resolve always has a frame — but Resolve is public, and the old fallback is gone

    • the string path trades transit for retention. Its total allocation drops, but roughly one message-length of that is no longer a transient buffer: the frame is retained for as long as the response is. Consumers holding many responses — a paged crawl kept in memory — should keep RawResult.ToArray() and let the frame go

    • measured per message on the socket path — the one production uses, since Connection binds OnBinaryMessage: 92 736 → 2 432 B, a 38x reduction. End to end on the typed path, where the removed document actually shows: 7.45x → 5.57x of the response size, 6.32 → 4.72 MB per 889 KB response

  • HasNextPage() returned false for every response, including paged ones — it compared Result against Dictionary<string, object>, which that member never was; it held a JsonElement. The method has no callers inside the SDK, which is how it survived. It now scans the raw result with Utf8JsonReader for a top-level marker, skipping over each non-matching member's value so a nested marker cannot be mistaken for the paging one

    • its test file existed as an empty stub — a class with no [TestClass] and no tests, a started-and-abandoned port of xrpl.js hasNextPage.ts. Worse, its fully qualified name carried no TestU, so it would not have run under the CI filter even with tests in it. It is now TestUHasNextPage with ten tests, including an escaped marker key, near-miss keys, non-object results, and a marker nested inside an array of objects
  • two allocation budgets, both measured rather than guessed. The existing one is annotated with what it cannot see: it asks for JsonElement, so one is built either way and the figure is identical before and after (1.89x). TestTypedResponseParsingStaysWithinItsAllocationBudget is the one that sees the change. TestUEnvelopeRetainsNoMoreThanTheFrame guards retention

  • BaseResponse.Id and ErrorResponse.Request were left as object at this point — each still building a JsonElement with an unreturned rental, measured at 3 672 B retained per envelope carrying an id against 217 B without one, on every response, and Sugar.SubmitAndWait catches txnNotFound in a polling loop where Request paid it repeatedly. Both are fixed further down in this same release: they became RawId and RawRequest, and the retention budget tightened from 8 192 to 6 144 bytes

  • LONFTokenPage.PreviousTxnLgrSeq changed from long to uint? (breaking) — the field is UInt32 per Base/Xrpl.BinaryCodec/Enums/definitions.json, matching every other ledger entry's PreviousTxnLgrSeq; the prior long was both wider than the protocol and inconsistent with its siblings

  • ServerState's StateLedger.ReserveBase/ReserveInc changed from uint to uint? (breaking) — the same class of defect as the rest of this section, but found on a Methods model rather than a ledger entry or transaction, which is exactly why it slipped past TestUNullabilityConformance: that test is built off ledger_entries.macro and the transaction formats, and ServerState is neither. A server_state response missing either field used to read back as 0, so Balances.GetXrpFreeBalance computed the account/owner reserve as zero and returned a free balance inflated by the reserve it failed to subtract. GetXrpFreeBalance now throws ValidationException when either field is absent, the same shape as the existing OwnerCount check beside it

  • 15 more response properties were non-nullable though the node genuinely omits them — found by cross-referencing, not by TestUNullabilityConformance. The same protocol field is modeled both nullable and non-nullable in different response classes in this repository; wherever that happens, at least one side is wrong. Each of the 37 non-nullable candidates that pattern turned up was checked against the actual condition guarding the field in the rippled C++ source (not xrpl.js — xrpl.js marks validated optional on fields rippled always sends unconditionally via lookupLedger, which would have "fixed" a healthy field and left a genuinely conditional one alone). 28 of the 37 are unconditional in rippled and stay as they are; 9 are conditional and are now ?. A later pass over the same ledger-subscribe paths found 6 more the cross-reference had not surfaced (fee_ref, fee_base, ledger_time, txn_count, ServerFeatures.Validated), which is where the table's count of 15 comes from - 16 declarations, since Closed is declared on both BaseLedgerEntity and the IBaseLedgerEntity interface beside it:

    • LOBaseLedger.LedgerIndex — unconditional for the dedicated ledger_closed command, but the same property is inherited by LOLedger for the general ledger command, where rippled's shared lookupLedger sets ledger_index only when the resolved ledger is closed; an open/current-ledger request gets ledger_current_index instead and omits this member entirely
    • HashOrTransaction.LedgerTransaction.Validated — structurally absent on a ledger response's expanded transactions when the request used API v1; rippled's LedgerToJson.cpp only writes it inside the apiVersion > 1 branch
    • BaseLedgerEntity.Closed (and IBaseLedgerEntity.Closed) — omitted from the nested ledger object for a non-binary ledger response when the ledger is open and full: true was requested
    • NoRippleCheck.LedgerCurrentIndex — same lookupLedger gate as LOBaseLedger.LedgerIndex above: absent whenever noripple_check resolves against a closed/validated ledger
    • ServerFeatures.LedgerIndex — worse than merely conditional: rippled's feature handler never calls lookupLedger and never writes ledger_index at all, so this member was fabricated as 0 on every response, not just some. The custom ServerFeaturesConverter had its own : 0 default baked into the read path, a second copy of the same defect the attribute-based fix elsewhere in this codebase does not reach
    • LedgerStreamResponse.LedgerIndex/ReserveBase/ReserveInc/LedgerTime/FeeBase — this class models the subscribe command's own synchronous reply when the client subscribes to the ledger stream (NetworkOpsImp::subLedger), a different rippled code path from the async ledgerClosed push that LedgerStream models; subLedger gates the whole block on ledgerMaster_.getValidatedLedger() returning non-null, so a node with no validated ledger yet omits every one of them from the initial reply even though the async push always includes them
    • LedgerStreamResponse.TxnCount — not conditional but absent outright: subLedger emits no txn_count on this path at all, which this type's own summary already stated ("does NOT include the 'type' nor 'txn_count' fields") while the property fabricated txn_count: 0 into every reply anyway
    • LedgerStream.FeeRef and LedgerStreamResponse.FeeRef — the everyday case, not an edge case. rippled guards fee_ref with if (!rules().enabled(featureXRPFees)) on both paths. XRPFees has been active on mainnet since 2023, so no current node sends the member at all: every ledgerClosed event a wallet round-tripped gained a fee_ref: 0 the node never wrote. On the subLedger path it is conditional twice over — inside the validated-ledger gate and behind the amendment check
    • ServerFeatures.Validated — the same feature-handler fact recorded one line above for LedgerIndex: the handler writes no validated either, and ServerFeaturesConverter carried the matching && v.GetBoolean() in its read path, which collapses "absent" and "false" into false. Its Write throws, so nothing was fabricated on output here — this one is about reading the node honestly, not about round-trip
    • ValidationStream.LedgerIndex — rippled's pubValidation sets it only when the underlying STValidation carries the optional sfLedgerSequence field
    • TestUNodeMayOmitProtocolFields proves it by round-tripping the exact node-omission shape for each one: deserialize, assert the property is null (not a fabricated 0/false), re-serialize, assert the JSON key is absent. Proven to fail, not merely pass, twice over: reverting HashOrTransaction.LedgerTransaction.Validated alone turned 2 of its 11 tests red, and reverting FeeRef turned 4 of 13 red
    • Everything else the same audit checked stays as it is, each for a rippled-sourced reason: AccountOffers' per-offer seq, account_tx's top-level validated/limit and each transaction's validated, account_info/account_currencies/account_nfts/account_objects's validated (all lookupLedger-unconditional), AccountInfo.AccountQueueTransaction.AuthChange, Fee.LedgerCurrentIndex/Drops.BaseFee, PathFindResponse/PathFindStream's full_reply, Subscribe.LedgerStream's ledger_index/reserve_base/reserve_inc (the async push, unconditional — contrast with LedgerStreamResponse above), BookChangesStream.LedgerIndex, ManifestStream.Seq, OrderBookStream/TransactionStream's validated, and Transactions.BookOffers.Offer.PreviousTxnLgrSeq (SoeRequired on every real ltOFFER). The seq sub-fields on LedgerEntry's PermissionedDomainQuery/OfferQuery/EscrowQuery are request parameters rippled rejects as malformedRequest when absent, not response fields, so they were never candidates for this fix despite surfacing in the same cross-reference
  • Stream events could not get at the bytes a node sent either — the named remainder of the raw-response work, called out in review as "repeat level 0 for the stream path" and left standing across three later levels. A query response gets RawResult because RequestManager.HandleResponse(byte[]) keeps the frame and pairs it with the envelope through AttachFrame. A stream message — transaction, ledgerClosed, and the rest — never went through that: Connection.EnqueueStreamMessage queued a string, already decoded from UTF-8 and with no frame behind it to point into. For a wallet, this is the one path that matters most: transactions reach it as a stream, not as a query response.

    • BaseStream — the base of every stream event except LedgerStream and ErrorResponse before this change — gained Raw and an internal AttachFrame(byte[]), mirroring BaseResponse. Unlike a query response, a stream message carries no result envelope to slice a member out of: the frame passed to AttachFrame is the event, so Raw spans the whole of it. JsonSlice gained OfDocument(byte[]) to compute those bounds - the same TokenStartIndex/Skip()/BytesConsumed technique JsonSliceConverter uses for a member, run once for the top-level value instead
    • LedgerStream now extends BaseStream rather than declaring its own Type field, which is what lets it carry Raw the same way every other stream event does. Found in passing: that field had a [JsonPropertyName]/[JsonConverter] pair on it but no [JsonInclude], and this library does not set IncludeFields - System.Text.Json does not serialize a public field without one or the other, so the field was never actually assigned by deserialization. Harmless in practice, since LedgerStream is only ever produced from a ledgerClosed message, but not by design; the inherited property fixes it as a side effect. Source-compatible - a field and an auto-property read and assign the same way - so nothing using LedgerStream.Type needs to change, but not binary-compatible: an assembly compiled against the previous package fails at runtime with MissingFieldException until it is rebuilt.
    • TransactionStream gained RawTransaction — the transaction alone, not the whole event Raw carries, which is the one thing a wallet displaying a transaction for signing actually asks for. Transaction and its private API v1 alias already claim tx_json and transaction as JSON property names, and System.Text.Json rejects a second member bound to a name another member already owns - so unlike every other slice in this codebase, RawTransaction cannot be filled through a converter-backed property. JsonSlice gained FindTopLevelMember(byte[], ReadOnlySpan<byte>) for exactly this case: it scans the frame directly, the same way RawJson.HasTopLevelProperty checks for presence, and TransactionStream.AttachFrame uses it to try tx_json first and fall back to transaction
    • Connection's stream pipeline now carries the frame, not text (not breaking: every member listed here is private, and no public signature changes type). _streamMessageChannel is Channel<byte[]>, not Channel<string>; ProcessStreamMessageAsync, ProcessStreamMessageFireAndForgetAsync, EnqueueStreamMessage and NotifyStreamProcessingErrorAsync all take the frame. OnMessage(string) — still public, still how every existing test feeds a message in by hand — builds a frame with Encoding.UTF8.GetBytes exactly the way RequestManager.HandleResponse(string) already does for the same reason, so nothing that called it needed to change. The binary path (OnBinaryMessage, what production actually uses) reuses the frame the socket produced instead of encoding a second copy: strictly less allocation than before, since the channel used to hold a UTF-16 string built from that same frame. OnWarning/OnServerWarning/OnError, which still take a string, materialize text lazily off the frame exactly as they did before this change - only later, and only when something is listening
      • the channel's bound is unchanged (10 000, DropOldest) and was not the concern here: a byte[] frame is roughly half the size of the UTF-16 string it replaces in the same slot, so the channel's worst case shrank, not grew. What was checked and confirmed instead: attaching a frame to a stream event costs nothing beyond the shared reference - TestUTransactionStreamAttachFrameRetainsNoMoreThanTheFrame measured 0 B marginal per instance for AttachFrame over 2 000 samples (budget 300 B), against a 744 B frame that a copy-per-instance regression would show up against almost in full
      • OnMessage(null) still cannot throw out of the entry point - it used to be routed to the stream processor as a stream message and reported through OnError as badMessage rather than raised, and carrying bytes instead of text must not turn that into a throw from Encoding.UTF8.GetBytes(null) at the frame-building step itself. TestNullMessageIsStillReportedThroughOnError pins it
    • TestUStreamRawJson covers the pipeline end to end - OnMessage through the channel to AttachFrame - rather than only the model in isolation: a ledgerClosed message with a field (network_id) no property models survives to Raw byte for byte and — since BaseStream gained [JsonExtensionData] — reaches a re-serialization of the typed LedgerStream as well, which is what that assertion was flipped to prove, and RawTransaction is checked against both the API v1 and v2 envelope, independently, through JsonDocument.GetRawText() rather than by re-deriving the same offsets the code under test computes
  • JsonSlice.FindTopLevelMember returned the first occurrence of a duplicate top-level member; JsonSerializer returns the last — rippled does not send a frame with two top-level tx_json members, but nothing between the socket and this code rules one out (an intermediate proxy, a compromised link). Such a frame left TransactionStream.RawTransaction pointing at the first occurrence while the deserializer-fed Transaction reflected the last, matching JsonSerializer's own last-value-wins behaviour for a POCO property fed by a duplicate JSON member (the default unless JsonSerializerOptions.AllowDuplicateProperties = false, which XrplJsonOptions.Default does not set) — a wallet would show a person one transaction and sign a different one. The scan now continues to EndObject and keeps the last match instead of returning on the first, matching the deserializer it feeds RawTransaction alongside

  • FindTopLevelMember/RawJson.HasTopLevelProperty matched case-sensitively while XrplJsonOptions.Default sets PropertyNameCaseInsensitive = true — a frame spelling the member "TX_JSON" populated the typed TransactionStream.Transaction through the case-insensitive deserializer while RawTransaction came back empty, since Utf8JsonReader.ValueTextEquals has no case-insensitive form. Both now decode the property name through Utf8JsonReader.GetString() (which also unescapes it, same as before) and compare with StringComparison.OrdinalIgnoreCase, matching the deserializer's own rule. RawJson.HasTopLevelProperty still returns on the first match rather than scanning to the end — presence does not depend on which occurrence is meant, unlike FindTopLevelMember's value lookup above

  • BaseStream.Type is ResponseStreamType? now (breaking) — the same class of defect as LedgerEntryType earlier in this release, found on review of the stream work directly above. LedgerStream()'s constructor stamped Type = ResponseStreamType.ledgerClosed unconditionally, so an instance built by hand (rather than through the deserializer) reported a type it was never actually given; separately, the non-nullable enum's default of ResponseStreamType.UNKNOWN (0) meant JsonSerializer.Serialize(new TransactionStream()) wrote back the literal member "type":"UNKNOWN" for an event that carried no type at all. The constructor is gone — deserialization off a real message already populates Type correctly, the same as every other property on these classes — and absence now round-trips as absence (XrplJsonOptions.Default omits a null member on write). Does not touch stream dispatch: Connection.ProcessStreamMessageAsync decides which typed class to build from BaseResponse.Type — an unrelated string property, deserialized separately from the raw JSON "type" member before the typed LedgerStream/TransactionStream/etc. instance exists — not from BaseStream.Type

  • The fabrication audit had no mirror image, so losses stayed. Everything above removes members the node never sent. The reverse — members the node did send, dropped on the way to a caller — was only closed for the shapes the fidelity corpus happened to have fixtures for. Closed the rest:

    • stream events dropped fields rippled writes unconditionally. network_id (NetworkOpsImp::pubLedger and subLedger, both paths), ctid (transJson, on every validated transaction) and the account_history_tx_index/_boundary/_tx_first trio (account_history subscriptions) had no property on any stream model and no capture to fall into. BaseStream now carries [JsonExtensionData], mirroring BaseLedgerEntry/BaseTransactionResponse/BaseMethodResult for their own families, so every stream type picks it up. LedgerStreamResponse declares its own, since it descends from BaseResponse, whose id/result members are byte-range slices rather than parsed values. Note that nothing routes through that type today — Subscribe returns XrplResponse<object> — so the capture there is correctness for whoever wires it up, not a live fix
    • ctid gets a real property on TransactionStream instead of a dictionary entry — a wallet asking which transaction is this needs it typed, the way Hash is
    • TestLedgerClosedRawSurvivesTheStreamPipelineByteForByte had used network_id as its example of a member the model has no place for, asserting the re-serialized output did not contain it. That pinned the loss as expected behaviour; the assertion is now flipped to prove the field survives
    • the capture reached 3 result models out of ~40, chosen by which fixtures existed. That is a test artifact, not a protocol boundary. 45 response projections gained it, bringing the total deriving from BaseMethodResult to 47, including TransactionSummary — what both tx and every account_tx entry deserialize into, and the shape whose status loss this file previously documented as a known remainder. Excluded, each for a reason: request-side shapes (the ledger_entry *Query selectors, Book/BookCurrency, SourceCurrency, TakerAmount, AuthorizedCredential), the two types whose custom converters own the read path (ServerFeatures, GatewayBalancesResponse), and the stream types already covered through BaseStream.
    • The first cut of that exclusion list was wrong, and the way it was wrong is worth recording. It was built by name, so four types that are read off a response and fed back into an outgoing one slipped through: Methods.Path (reaches Payment.Paths and PathFindCreateRequest.Paths), AuthAccount (AMMBid), and AuthorizeCredentialEntry/AuthorizeCredentialBody (DepositPreauth). Capture on those let a member read from one node's response ride back out inside a transaction the user never put it in — and StObject.FromJson passes signingOnly only to the top level, so a nested unknown member reaches the displayed tx_json but not the signed blob. Show one, sign another: the exact failure this release removes, arriving from the outgoing side. The rule is reachability from the request graph, not the shape of the name
    • KnownLostMembers in TestUResponseFidelity is now empty — every member of every captured mainnet response survives the round trip, in both directions. The table stays as the mechanism that keeps it so: a model that stops carrying a field fails the test until someone writes down why. Proven by mutation, removing [JsonExtensionData] from BaseMethodResult turns it red
  • Four ways RawTransaction and the typed Transaction could disagree — a wallet showing one transaction and signing another. Each is the defect this release exists to remove, arriving from the opposite side:

    • JsonSlice.FindTopLevelMember returned the first occurrence of a duplicated key and stopped scanning; System.Text.Json takes the last. It now scans to EndObject and returns the last, matching the deserializer
    • matching is now case-insensitive, because XrplJsonOptions.Default sets PropertyNameCaseInsensitive = true — a frame carrying TX_JSON filled the typed Transaction while leaving RawTransaction empty
    • TransactionStream.AttachFrame preferred tx_json unconditionally, while the typed side takes whichever envelope appears later (its two setters both do value ?? _transaction and run in document order). Both views now resolve the same envelope
    • an envelope explicitly set to JSON null is skipped rather than winning that ordering rule. value ?? _transaction discards a null, so {"tx_json":{…},"tx_json":null} leaves the real object on the typed side — resolving the slice to the trailing null emptied RawTransaction while Transaction still held a payment, showing a wallet nothing while it signed something. Filtering happens per occurrence inside the scan, on the token type, so it composes with the duplicate rule above rather than overriding it
    • rippled sends neither duplicate keys nor both envelopes, but the frame reaches this library over the network through arbitrary infrastructure, so the two views must not be able to disagree at all. Each is pinned by a test proven to fail against the previous behaviour
  • What unknown-field capture costs, measured rather than assumed. The dictionary is not allocated at all when a response has no unrecognized member, so widening the capture to 62 models is free for every response the SDK already models fully. When a member is unrecognized, System.Text.Json parses each one into its own JsonDocument (pooled buffer, metadata table, key string), which costs about 464 B per captured member — roughly 15x the 31 bytes of JSON it stands for. That multiplies by nesting depth, which is the part worth knowing: an account_lines page of 1 000 trust lines, each carrying one field this SDK does not model, goes from 320 KB retained to 792 KB — 4.33x the JSON's own size, against 1.75x before, when the field was simply dropped. A single large unknown member is cheaper in proportion at about 1.79x.

    • that measurement is why TransactionStream declares account_history_tx_index/_boundary/_tx_first as properties rather than leaving them to capture, alongside ctid: rippled sends account_history_tx_index on every event of such a subscription, and the two flags on some (_boundary marks the last transaction of a ledger, _tx_first the earliest the account ever had). Captured, the three cost ~796 B on an event carrying all of them — on the one path a wallet cannot avoid
  • Locating a member no longer allocates. Making the name match case-insensitive (so a key that fills a typed property cannot read as absent) had been written with reader.GetString(), which materialized every top-level key of every frame — ~760 B per scan, twice per stream event, on a struct whose whole purpose is to record where a value sits without materializing it. Utf8JsonReader.ValueTextEquals now answers the ordinary case against the raw bytes and only a differently-cased or escaped key falls through to the allocating comparison, which keeps the result identical to what the serializer does under PropertyNameCaseInsensitive

  • Three UnknownFields declarations were removed from LOLedger, LedgerEntity and LedgerBinaryEntity: their bases had gained the same property, and a duplicate in one hierarchy compiles (CS0108) with System.Text.Json binding the derived one — so the base property stayed null forever while the data sat on the subclass. LedgerClosed hands callers an LOBaseLedger, which would have read empty

  • ErrorResponse remains the one stream-path type without a whole-event Raw: it descends from BaseResponse, not BaseStream, and gets RawResult/RawId/RawRequest instead. Named here so it is a known boundary rather than an unnoticed gap

  • Stream events reach IXrplClient, so the raw bytes on them are usable through the SDK's own contract (#103). Everything above gives a stream event Raw and RawTransaction — the point being that a wallet can show a person the transaction a node actually sent before they sign it. Transactions arrive by stream, and the only way to receive one was client.connection.OnTransaction: a property of a concrete class, so code written against IXrplClient could neither subscribe nor be exercised against a substitute client. The feature existed without a contract to reach it through.

    • every event Connection raises is declared on IXrplClient and forwarded to it. client.connection.OnX keeps working unchanged — this adds a surface, it does not move one
    • forwarded, not relayed, and the distinction is the whole design: add/remove go straight to the same Connection, so the client holds no delegates, no subscriber list of its own, and no subscription that nothing removes. A relaying version would add all three, plus a second place to keep in sync. TestUUnsubscribingThroughTheInterfaceRemovesTheHandler pins it by crossing surfaces — subscribe through the client, remove through the connection — because removing through the same surface passes either way
    • this is safe because the Connection outlives the client: it is assigned once, in the constructor, and ChangeServer swaps the session inside it rather than the object, so subscriptions survive a server change
    • IXrplClient.connection lost its setter (breaking, though nothing in the tree assigned it). With handlers attached through the events above, replacing the connection would strand every one of them on the old object and the stream would go quiet with nothing to show for it
  • Stream messages discarded because handlers fell behind are counted rather than lost in silence (#105). Events reach handlers through a bounded queue, so a slow handler costs events instead of stalling the socket - the right trade, made in 3c7e38e. What it left was no trace at all: the queue drops its oldest entry when full, nothing throws, nothing logs, and a consumer building state from the stream drifts from the ledger with no way to tell.

    • Connection.DroppedStreamMessages and IXrplClient.DroppedStreamMessages count the discards, across reconnects and ChangeServer alike, since one Connection serves them all. Any increase means events arrived and never reached a handler
    • ConnectionOptions.StreamMessageQueueCapacity (default 10 000, unchanged) sizes the queue - raise it for a consumer that must not miss events and can hold the frames, lower it to bound memory harder
    • the counter is incremented from Channel's itemDropped callback, which runs inside TryWrite on the receive loop. It does nothing but increment for that reason: raising an event or logging there would put consumer code back on the path this queue exists to keep it off - the very failure #105 described but which the queue already prevented
    • browsers no longer take a separate path (#110). EnqueueStreamMessage used to start one fire-and-forget task per frame under WebAssembly, bypassing the queue: the capacity was not consulted, nothing was evicted, the counter stayed at zero however far handlers fell behind, the backlog was bounded by nothing, and concurrent dispatch could hand handlers events out of the order the node sent them. The queue was built for that environment to begin with - StartMessageProcessor says "true async support in WebAssembly single-threaded environment" - and measurement confirms it works there. Running the Blazor demo against mainnet: 1 004 transactions over 52 s (19.2 tx/s, 13 ledgers) through the queue, no console errors, timestamps in order, against 462 over 33 s (13.9 tx/s) on the bypass. The platforms no longer diverge on the queued path: capacity, eviction counting and single-reader ordering hold on every target, for frames that enter the queue. One window remains and is not platform-specific - StartMessageProcessor runs after the OnConnected callback, so a handler subscribing there can see frames before the channel exists, and those take the direct fallback. Moving the start earlier is blocked by StartPingTimer calling StopPingTimerSync, which stops the processor too; tracked separately
    • the issue's premise was checked and does not hold: handlers do not run in the receive loop. A queued frame is dispatched by the background reader, and the receive loop does no more than TryWrite; a frame on the fallback path is handed off from the receive loop, but the hand-off yields before it parses anything, so the receive loop does not synchronously deserialize or call handlers there either - the yield promises asynchrony, not a different thread, which is the whole promise needed here. What is real is the silent loss, which is what this addresses
  • Frames from a socket being retired no longer reach handlers as if they were current (#112). Session identity was threaded into every lifecycle callback - OnceOpen, OnConnectionFailed, OnceClose all compare against _activeSession.SessionId - and the message path was the one exception: ws.OnBinaryMessage called IOnMessageFastPath(m) with no session at all.

    • that mattered because retirement is not instant. RetireOldSessionAsync runs fire-and-forget beside the new connection and closes the old socket gracefully, so it keeps delivering for the length of the close handshake with its callback still attached. Whatever it sent landed in the new session's queue
    • after a reconnect those frames are stale. After a ChangeServer between networks they are worse than stale: switching mainnet to testnet, a handler that believes it is on testnet could receive the tail of mainnet's stream - transactions for accounts that do not exist there, ledger indexes from another chain, nothing marking them as belonging to the previous connection
    • the session now travels with the frame - through the queue, not merely as far as it - and is checked twice: on the way in, to save a queue slot, and again immediately before handlers run, which is the check that guarantees anything. Checking only on the way in cannot: the channel is rebuilt per session under a different lock, and a frame accepted for the live session can still be dequeued long after that session is gone - the queue holds up to StreamMessageQueueCapacity frames (10 000 by default)
    • matching the id alone would not do either: both paths call MarkAsRetiring() while the session is still the active one and only ConnectInternalAsync installs its replacement, so frames arriving in that window carry an id that matches. The retiring flag is part of the test, under _sessionLock - the same guard OnceOpen and the other lifecycle callbacks already use, and the only way IsRetiring is published at all
    • a null session means the caller has none to name (OnMessage, which anyone may call), and nothing is rejected in that case
    • consequence worth naming: OnMessage no longer dispatches inline when there is no processor. It never did on the queued path, so this makes the two agree - but code that called OnMessage on an unconnected client and read handler state on the next line was relying on the difference
    • what goes round the queue is now countable. FallbackDispatchedStreamMessages counts frames dispatched outside it - no capacity bound, no eviction counting, no single-reader ordering apply to them. Three things send a frame there: the processor not being up yet, the processor having been stopped, and a refused write. The first is a real window on every connect, and this turns it from something argued about into something measured
    • all three counters are declared with default bodies on IXrplClient, forwarding to connection - the only implementation that means anything. DroppedStreamMessages was declared without one in this same unreleased cycle; giving it one too costs nothing and keeps an external implementation of the interface compiling
    • the fallback path hands the frame off before doing any work. An async method runs on its caller's thread up to the first real await, and the first real await inside ProcessStreamMessageAsync comes after JsonSerializer.Deserialize - so every frame taking the fallback had its JSON parsed on the receive loop, plus whatever a handler did before its own first await. That is the head-of-line blocking the queue exists to prevent, reintroduced for the startup window, for a stopped processor and for a refused write. A yield at the top of the fallback ends it
    • a frame the channel refuses no longer vanishes. TryWrite was called for its side effect and its result ignored, on the reasoning that a DropOldest channel never refuses - which is true of a full queue (it evicts, counts through itemDropped and reports success) and false of a completed one. StopMessageProcessorInternal completes the writer after clearing _streamMessageChannel, so whoever read the reference an instant earlier writes into a closed channel and the frame was dropped with nothing to show for it. Not a corner case: StartPingTimer tears the processor down and StartMessageProcessor builds it again on every connect. Such a frame now takes the fallback path, where it still faces the session check
    • Connection.StaleSessionFramesDropped counts what was discarded, kept separate from DroppedStreamMessages because the two mean different things: a non-zero value here is normal right after a reconnect, while the other means consumers are falling behind. Also on IXrplClient, next to DroppedStreamMessages - a counter nobody can read is not observability. New interface member, with a default body forwarding to connection: an external implementation of IXrplClient keeps compiling and may override it

10.12.0.0 16/08/2026

  • Request(Dictionary<string, object>) never delivered the API version, so one client spoke two protocol versions (breaking) — it stamped the version under nameof(ApiVersion), literally "ApiVersion". A dictionary is serialized verbatim, and rippled knows only api_version: it ignores unknown fields and answers on its default, API v1. Measured on mainnet, the three spellings are not equivalent — api_version: 2 returns the v2 shape, while "ApiVersion": 2 and no version field at all both return v1. So client.AccountInfo(…) went out as v2 while client.Request(new Dictionary { ["command"] = "account_info" }) on the same client went out as v1, and response shapes differed between the two with nothing to signal it. The typed path was never affected: BaseRequest.ApiVersion carries [JsonPropertyName("api_version")].

    • the key is now the wire name, and a version the caller put in the dictionary themselves is still respected. The junk "ApiVersion" field no longer rides along on every request
    • breaking: callers of the untyped path move from API v1 to whatever ApiVersion says, which defaults to 2 — response shapes change under code that did not change. This is the fix, not a side effect: the previous behaviour ignored the setting entirely. Callers who want v1 can put ["api_version"] = 1 in the dictionary or set ApiVersion on the client
    • TestURequestApiVersion reads what the client actually puts on the wire through a request-capturing WebSocket server — a field the node ignores cannot be seen from the response, which is how this survived. It pins the wire name on the untyped path, that an explicit api_version is not overwritten, and that both request paths of one client carry the same version. WebSocketTestServerBase gained the client-frame reader that PagedResponseServer had kept private, rather than a third copy of it
  • TransactionStream re-parsed the transaction on every read of it, and lost the hash under API v1 (breaking) — the same defect TransactionSummary was fixed for in 10.9.1.0, left standing on the stream side. Transaction was an expression-bodied property over two object members holding JsonElements: JsonSerializer.Deserialize<TransactionResponse>((TransactionJson ?? Proposed).ToString(), …). Three things wrong with that one line, on the busiest path the client has — every transaction of a transactions subscription:

    • the transaction was rendered back to a string and parsed a second time. It was already parsed: TransactionJson/Proposed are object, which System.Text.Json fills with a self-contained JsonElement. Same round trip as the one removed from RequestManager.Resolve below
    • nothing was cached, so the expression ran again on every access. Measured over 300 real mainnet stream messages: one read cost 4.94 KB (API v1) / 3.96 KB (v2), three reads cost exactly three times that — 14.82 KB and 11.89 KB. A consumer reading TransactionType and then Hash paid twice, and nothing in the property's signature said so
    • the hash was unreachable under API v1. rippled reports it at the top level under v2 but only inside the envelope under v1, and Hash was mapped to the top-level field alone, so tx.Hash was always null on v1 — which is what left the Blazor-WebAssembly demo printing no hash, since it requests "ApiVersion": 1. Verified against mainnet in both directions
    • a message carrying neither envelope threw NullReferenceException straight out of the property

    TransactionStream now follows TransactionSummary: Transaction is typed TransactionResponse and mapped to tx_json, a private set-only TransactionV1 alias catches the API v1 transaction envelope, and Hash falls back to the envelope. The transaction is deserialized once, with the message that carries it — there is no second parse left to cache, and reading the property back is a field read. ledger_index and ledger_hash needed no fallback: rippled reports both at the top level in either version, which the captures confirm.

    • measured over the same 300 messages per version, allocation per message for the whole consumer flow — deserialize the message, then read the transaction off it: 16.44 → 13.75 KB at one read and 26.32 → 13.75 KB at three (API v1); 15.24 → 13.07 KB and 23.17 → 13.07 KB (API v2). The figure no longer moves with the number of reads at all, which is the point. Timings did not separate reliably on the measuring machine and are not quoted
    • the trade-off, stated plainly: deserializing the message alone went up, 11.50 → 13.75 KB (v1), because the transaction is now materialized eagerly instead of being left as a lazy JsonElement. A consumer that never touches Transaction pays about 2.25 KB more per message; one that touches it once or more pays 2.7–12.6 KB less
    • breaking: the public object properties TransactionJson and Proposed are gone — they existed only as raw envelopes for the getter to re-parse, and there is nothing left to re-parse. Transaction keeps its name and type and gains a setter. Consistent with the removal of Path.TypeHex in 10.11.0.0, no [Obsolete] grace period
    • TestUTransactionStreamEnvelope pins both envelopes, the hash under both versions, the message carrying neither, and that repeated reads allocate nothing and hand back the same instance
  • Every response was parsed twice and copied to UTF-16 twice — the cost of reading a response, measured rather than reasoned about. RequestManager.Resolve did JsonSerializer.Deserialize(response.Result?.ToString() ?? "{}", taskInfo.Type, ...). BaseResponse.Result is typed object, which System.Text.Json fills with a JsonElement that already owns a private copy of the result bytes — so .ToString() rendered that element back into a UTF-16 string and the serializer parsed the string a second time. On a ledger_data page at limit=2048 (~1 MB) the four stages measured, per response, at: 1.97 MB for the UTF-16 copy of the message, 1.68 MB for the document built over it, 1.97 MB for the UTF-16 copy of the result, 1.68 MB for the second document — 7.30 MB, 7.42x the response size, all four allocations past the 85 KB large-object threshold. Both halves are now gone:

    • DeserializeResult works off the parsed node: element.Deserialize(type, options) for a typed model, and the element itself when the request asked for JsonElement or object, which is what a consumer that needs the raw ledger objects asks for (the typed LOLedgerData.State drops unknown fields). A BaseResponse assembled by hand rather than parsed off the wire keeps the old string path. Behaviour is otherwise unchanged, including a missing or JSON-null result, which still yields what deserializing "{}" yielded
    • the socket path carries the frame as it arrived. Connection binds OnBinaryMessage instead of OnMessageReceived, IsLikelyResponse and RequestManager.HandleResponse have ReadOnlySpan<byte> overloads, and the UTF-16 string is materialized — once, lazily — only for what genuinely needs text: stream messages and the OnWarning/OnServerWarning/OnError callbacks. The string overloads stay for Connection.OnMessage(string) and for external callers
    • the warning callbacks no longer pay for listeners that are not there. rippled attaches warning/warnings to responses under load and on a reporting-mode server, and the dispatch built the UTF-16 text for them before checking whether OnWarning/OnServerWarning were subscribed — on such a server that is the removed allocation, back on every page. Measured with warnings on all 20 pages and nothing subscribed: 4.28x → 2.08x
    • the failure report survives the failure. A response that will not parse is most often a heap that has just run out, and materializing the message for OnError is then the largest allocation left on the path — if it throws, the notification is lost inside the handler and the consumer sees silence. The text is now built only when a handler is attached, and an OutOfMemoryException while building it falls back to a literal placeholder so the classification still goes out. Connection.OnMessage(null) also keeps its old route through OnError instead of throwing ArgumentNullException out of the entry point
    • measured end to end against a local WebSocket server, 600 ledger_data pages of ~1 MB: 8.32 → 2.68 MB allocated per response (8.46x → 2.72x the payload), 11.49 → 7.96 ms per response, 87 → 126 responses/s, peak managed heap 42.4 → 20.8 MB, peak LOH 39.2 → 17.3 MB, peak working set 203.4 → 71.8 MB. Under a lowered DOTNET_GCHeapHardLimit the pre-fix path reproduced the production failure exactly — XrplException: Failed to deserialize response for request <id>: Exception of type 'System.OutOfMemoryException' was thrown, with JsonElement.ToString() at the top of the inner stack — at a ceiling the fixed path completes 15/15 pages under
    • the win is not specific to ledger_data or to JsonElement: the second parse was on the path of every command. The repo's own BenchmarkLedgerDataCrawl, which goes through RequestDictionary<string, object>, drops from 22.9 to 14.8 MiB allocated per 2 MiB page (11.4x → 7.4x) with LOH ending at 38.2 instead of 115.4 MiB — it stays above the JsonElement figure because building a Dictionary<string, object> boxes every value, which this change does not address
    • TestUResponseParsing pins the behaviour that had to survive — the untyped node handed through is self-contained and readable after a forced gen2 collection, a typed model deserializes to the same values, the string and UTF-8 overloads agree, a missing result still completes, an error status still rejects with the parsed ErrorResponse attached, a null message does not throw out of the entry point — and holds two allocation budgets at 4x the response size. The first measures RequestManager alone, per thread so the class-parallel run cannot perturb it (1.89x now). The second runs 20 pages through Connection over a real socket, because nothing else in the suite can see which overload the client picks: it reads the process-wide counter and is therefore kept out of the parallel pass, and it separates the two paths with room on both sides — 2.18x as bound, 4.84x with the string callback bound instead. PagedResponseServer reuses one response frame per connection and rewrites the id in place so the server contributes nothing to what the client is measured on
  • error responses were deserialized a third time — the status == "error" branch of HandleResponse re-parsed the whole message into an ErrorResponse inside a try/catch that swallowed everything, to build the exception's Response. The message had already been deserialized into an ErrorResponse at the top of the same method; the second parse only produced an equal copy, and on a large error payload it was a second large-object allocation on a path that is already failing

10.11.1.0 13/08/2026

  • Fix infinite recursion in LONFTokenConverter.Write — the metadata of an NFT transaction could not be serialized at all — regression introduced in 10.3.0.0 with the Newtonsoft.JsonSystem.Text.Json migration; affects every release from 10.3.0.0 on. JsonSerializer.Serialize(tx.Meta) threw JsonException: A possible object cycle was detected for any transaction whose AffectedNodes contain an NFTokenPage, which is every NFTokenMint, NFTokenBurn, NFTokenAcceptOffer and NFTokenModify that touched a page. Verified against mainnet on all six NFT transaction types — the four above failed, NFTokenCreateOffer and NFTokenCancelOffer (no page in their metadata) went through:
    • The converter broke its own recursion the way the other polymorphic converters do — strip itself from options.Converters via JsonSerializerOptionsCache.WithoutConverter<T> and re-enter the serializer. That works only for a converter that is registered in the list. LONFTokenConverter is declared as a [JsonConverter] attribute on the NFToken type itself (LONFTokenPage.cs), and a converter attached to a type outranks the options list, so System.Text.Json handed the value straight back to Write no matter what the list looked like. The frame repeated until the writer hit MaxDepth. Raising MaxDepth is not a workaround: at 64 and 128 it is a catchable JsonException, at 256 the stack overflows and the process dies
    • NFToken has two fields, so Write now emits them directly instead of delegating. The wire shape is unchanged — {"NFToken":{"NFTokenID":"…","URI":"…"}}, the envelope Read already looks for — and the documented null behaviour is preserved by honouring options.DefaultIgnoreCondition rather than hard-coding one: XrplJsonOptions.Default (WhenWritingNull) omits a null URI, plain options keep it as null
    • The six other converter types that call WithoutConverterLOConverter, GenericStringConverter<T>, MetaBinaryConverter, LedgerBinaryConverter, TransactionRequestConverter and TransactionResponseConverter — were audited against the same two conditions — declared as a type-level attribute and re-serializing that same declared type. None hit both. LOConverter is registered in the options list (its one attribute use is property-level) and writes the concrete runtime type; GenericStringConverter<T>, MetaBinaryConverter, LedgerBinaryConverter and TransactionRequestConverter are only ever attached to properties. The three node converters (CreatedNodeConverter, ModifiedNodeConverter, DeletedNodeConverter) do not call WithoutConverter at all and so are not among those six, but they are type-level and were checked for the same trap anyway: they serialize a different class (value.NewFields.GetType()). TransactionResponseConverter is the one other type-level case, and the same trap was already defused there by the TransactionResponseUnknown sentinel, so that no value ever carries the annotated type at runtime. Nothing else was changed
    • TestULONFTokenConverter had Read coverage only, which is how the bug survived. It now pins the written shape, both round trips (URI set and null), null handling under XrplJsonOptions.Default and under plain options, a multi-token NFTokenPage, and — the regression test proper — serializing a Meta carrying an NFTokenPage in CreatedNode.NewFields, ModifiedNode.FinalFields, ModifiedNode.PreviousFields and DeletedNode.FinalFields, since the page can arrive in any of them. All offline, on prepared JSON
  • WebSocket message assembly was quadratic in the number of receive chunksReceiveLoopAsync grew a multi-chunk message with byteResult = byteResult.Concat(buffer.Take(result.Count)).ToArray(). Every chunk allocated a fresh array the size of everything received so far and refilled it one byte at a time through a LINQ enumerator, so a message split into k chunks copied roughly k/2 times its own length; every intermediate array was well past the 85 KB threshold and therefore landed on the uncompacted large object heap. ledger_data at limit=2048 is a few megabytes and arrives in dozens of chunks over a real link, which is exactly where the cost concentrates. Chunks are now Buffer.BlockCopy-ed into a scratch buffer that grows to the largest message on the connection and is reused from then on; a message that arrives whole in one chunk skips the scratch entirely, and both the receive buffer and that scratch buffer are rented from ArrayPool rather than allocated per connection (measured on .NET 10: the shared pool does hand back the same multi-megabyte array after a return, so this is a real saving and not just indirection). Measured on a local fragmenting WebSocket server, 300 messages of 2 MiB, allocation per message: 3.50x payload at one chunk, 6.50x at eight, 18.52x at thirty-two — now a flat 3.01x, which is the floor (the exact-sized byte[] plus the UTF-16 string handed to the callback). Through the full client stack, a 3000-page ledger_data crawl with each page arriving in 32 chunks and a consumer retaining every object: 100.7 s → 55.8 s, 158.4 GiB → 67.3 GiB allocated, 891 → 398 gen2 collections, and the last-decile-to-first-decile page time drops from 1.39x to 1.13x. ReceiveChunkSize was measured at 1 MiB and 64 KiB and left at 1 MiB — now that the buffer is pooled, shrinking it changed nothing outside run-to-run noise. TestUWebSocketMessageAssembly pins the byte-exactness of a 96-chunk message, that a short message after a long one picks up no stale bytes from the reused buffer, and that per-message allocation stays under 12x payload at 64 chunks (34.5x before the fix). A dead timedOut local, declared and tested but never assigned since it appeared, is gone
  • Request timeout timers outlived their requestsRequestManager.Resolve/Reject called timer.Stop(). System.Timers.Timer derives from Component and carries a finalizer, so every completed request left a finalizable object behind, each of them holding its request's serialized text alive through the Elapsed closure; over a long paged crawl that is thousands of them. Dispose() stops the timer and takes it off the finalization queue. A second, worse case sat next to it: a token that is already cancelled runs its Register callback inline, so Reject completed the request in the middle of the factory method — before the timeout timer existed and therefore with nothing to remove. The factory then registered the timer for a promise that was already gone, and when it fired, Reject took its missing-promise early return without removing it, so the entry stayed in timeoutsAwaitingResponse for the life of the process. The CancellationTokenRegistration leaked on the same path, its assignment to TaskInfo happening after DeletePromise had already run. Both factories now check whether the promise survived and clean up after themselves; timer removal moved into DisposeTimeout, which is also called on the early returns of Resolve and Reject and so closes the narrow race with a concurrent cancellation as well. TestURequestManagerCancellation pins that an already-cancelled token leaves neither timer nor promise behind in either factory, and that a live request still arms its timeout and releases it on completion
  • Reflection on the per-response path is goneResolve, Reject and ObserveTaskException reached for TrySetResult, TrySetException and Task through GetType().GetMethod(...) + Invoke on every single response. TaskInfo now carries typed SetResult/SetException delegates and the CompletionTask itself, wired when the request is created. The properties were added rather than substituted: TaskInfo is public, so instances built outside RequestManager keep the old reflective path
  • Dead tasks field removed from XrplClientprivate readonly ConcurrentDictionary<int, TaskInfo> tasks was never assigned and never read, so it was permanently null; a leftover from when the client tracked pending requests itself, which RequestManager has done for a long time

10.11.0.0 04/08/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
    • 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 watchernightly-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
  • Cancellation no longer disappears into the autofill fee fallbacksFetchCounterpartySignerCount 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 tagtransactions.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 thisdefinitions-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 feeTransactor::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

  • 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<T>) 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 fixesTestUtils.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 modelsPreviousTxnID/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, 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 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
  • TestULedgerFlagsConformance — the guard that would have caught all of the aboveLedgerFormats.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/lsif/tif prefixes, so lsfMPTLockedMPTLocked 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 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 foreverConnection.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 ownsStopReconnectLoop() 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 29/07/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
    • headers are put on the handshake as-is; the type changed from Dictionary<string, object> to Dictionary<string, string> to match xrpl.js and drop the ToString() ambiguity (source-breaking, but the property was inert, so no working code can depend on it)
    • both are skipped under WebAssembly — the browser WebSocket API cannot set request headers, so ClientWebSocket.Options.SetRequestHeader is guarded by OperatingSystem.IsBrowser() the same way KeepAliveInterval already was
    • proxy, proxyAuthorization, trustedCertificates, key, passphrase, certificate and the unused trace/Trace pair are removed rather than implemented (breaking, no [Obsolete] grace period — consistent with the 10.9.0.0 hex-helper removals): current xrpl.js has dropped these options too, they cannot be honored uniformly across ClientWebSocket targets, and nothing in the solution ever read them. A property that silently does nothing is worse than one that does not compile
    • Scope note: rippled does not check Basic auth on the ws/wss handshake — authorized() is called only from the plain-HTTP onRequest() path, while onHandoff() upgrades WebSockets without it. A port stanza's user/password therefore only guards HTTP JSON-RPC. authorization is for reaching a node behind a reverse proxy or a provider that requires Basic auth
  • AdminUser/AdminPassword — admin commands over WebSocket — the mechanism rippled actually accepts for ws/wss: admin_user/admin_password travel inside the request JSON, not in a header. Without them, a port that sets admin_user/admin_password rejects ledger_accept, stop, connect and friends outright — forbidden / Bad credentials. — regardless of the client's IP, because requestRole returns Role::FORBID rather than demoting the client to guest. Both must be set for either to be sent, mirroring rippled's own check (a matching admin net and correct credentials)

    • injected into the serialized request rather than into the request object, so the credentials never reach the TimeoutException message that consumers log — TestAdminPasswordIsNotLeakedIntoTimeoutMessage pins that
    • RequestManager.CreateRequest/CreateGRequest take the credentials as a trailing optional parameter, so existing positional call sites are unaffected
  • CoverageTestUAuthorization asserts against the raw HTTP upgrade text captured by a loopback socket server: Basic header present and correctly encoded, custom headers present, and no Authorization header when the option is unset. TestIAdminCredentials runs against a new [port_ws_admin_auth] stanza on the standalone stand (port 6007, admin_user/admin_password set) and checks both directions — ledger_accept rejected with forbidden / Bad credentials. without credentials, accepted with them. The port is separate from port_ws_admin so the rest of the integration suite is untouched

  • Transaction fields declared by the protocol but missing from the modelsTxFormat listed them and the binary codec knew them, so the values travelled fine through Dictionary<string, object>, but the typed models had no property: reading silently dropped them and the typed API could not set them at all. A field-level diff of TxFormat against the transaction models found four such names; the earlier 10.7.0.0 completeness pass had closed the ledger-object side (LOAccountRoot.WalletLocator/WalletSize) but not the transaction side:

    • TransactionRequest/TransactionResponse + Delegate and OperationLimit — both are rippled common fields (TxFormats.cpp commonFields), valid on every transaction type, so they belong on the shared base rather than on individual transactions. Delegate identifies a transaction submitted under DelegateSet permissions (previously readable only from raw JSON, though BatchUtils already honored it when collecting required batch signers). OperationLimit is inert on XRPL but is the marker Xahau's Burn-2-Mint reads on a burn — consumers no longer need to build the burn as a dictionary to get it onto the wire, nor read raw JSON to tell a burn from a plain AccountSet
    • AccountSet/AccountSetResponse + WalletLocator and WalletSize — both still stand in rippled's AccountSet format (transactions.macro). WalletSize is legacy and not acted on by the transactor; it is exposed so a transaction carrying it survives a round trip
    • ValidateBaseTransaction type-checks the two new common fields, as it already does for every other common field; ValidateAccountSet does the same for the two new AccountSet fields — WalletSize as a UInt32, and WalletLocator as a 256-bit hex value, which is the rule sfWalletLocator's Hash256 type implies and the one the SignerListSet validator already applies to a SignerEntry's WalletLocator
    • Target deliberately not added — it is not a protocol field: sfTarget is retired (AccountID nth 7 is marked unused in sfields.macro, and the name is absent from definitions.json), and since the TicketBatch amendment rippled's TicketCreate carries only sfTicketCount. The stale Target/Expiration entries were removed from TicketCreate in TxFormat; Field.Target stays in the codec so historical blobs still decode
    • TestUTransactionProtocolFields pins the whole cycle — deserialization, ToJson/ToDictionary round trip, typed-vs-dictionary signing parity byte for byte, and, as the regression guard for touching the common base, blobs of transactions that set none of the new fields against signatures captured from 10.9.1.0
  • TxFormat brought into full conformance with rippled, and held there — the table is inert at runtime (TxFormat.Validate is not on the signing path; the codec serializes from definitions.json), so wrong entries produced no symptom and nothing in the suite noticed. A field-by-field diff against rippled transactions.macro found seven wrong formats out of 82; all are corrected and the table now matches upstream exactly:

    • CheckCreate, CheckCash, CheckCancel — all three were a verbatim copy of the PaymentChannelClaim entry above them (Channel/Amount/Balance/Signature/PublicKey). Now CheckCreate = Destination+SendMax required, Expiration/DestinationTag/InvoiceID optional; CheckCash = CheckID required, Amount/DeliverMin optional; CheckCancel = CheckID required
    • NFTokenMint — was missing Amount/Destination/Expiration; the NFTokenMintOffer fields reached the model in 10.7.0.0 but the format never followed, so the two had silently drifted apart from each other
    • OracleSet — dropped BaseAsset/QuoteAsset/AssetPrice/Scale, and SignerListSet dropped WalletLocator: in both cases these are members of a nested object (PriceDataSeries entries, SignerEntry) that had been hoisted to the top level
    • VaultCreate — dropped Amount, which is not a field of that transaction
    • TestUTxFormatConformance now diffs every one of the 82 formats against a vendored, ref-pinned copy of transactions.macro (Tests/Xrpl.Tests/Fixtures/) and reports each divergence by name. Pinned rather than live on purpose: upstream drift is already protocol-watch's job (transactions.macro is in its watch list), and a network-backed test would go red on Ripple's release schedule instead of ours. The parser fails loudly on an unknown Soe* keyword or a short parse, so a macro-layout change cannot turn the guard green on an empty table
  • Fix CheckCreate.InvoiceID: uint?string (breaking signature change, though nothing could have depended on it) — sfInvoiceID is a Hash256, Payment.InvoiceID was already string, and ValidateCheckCreate already rejected anything but a string. The typed property was uint?, so every non-null value threw at signing time (Can't decode `InvoiceID` from `123` ): the field was unusable through the typed API in any release that had it. Found while writing the integration coverage for the corrected CheckCreate format

  • Integration coverage for the corrected field sets (TestIProtocolFieldSets, standalone stand) — TxFormat itself cannot be exercised end-to-end, so these pin the claim underneath it against a real node: CheckCreate carrying Expiration/DestinationTag/InvoiceID lands and the Check object reads them back; CheckCash settles through the previously untested DeliverMin branch; NFTokenMint with Amount/Destination/Expiration creates the mint-time sell offer; and an AccountSet with WalletLocator/WalletSize/OperationLimit survives a full ledger round trip back into the typed AccountSetResponse — the end-to-end proof for the model work above. Delegate is covered by TestDelegatedPayment_DelegateFieldSurvivesTheLedgerRoundTrip (amendment-gated on PermissionDelegationV1_1, so it runs on the nightly stand): the owner grants the Payment permission, the delegate signs a Payment whose Account is the owner and whose Delegate is itself — without the field rippled would reject the signature outright — and the transaction reads back into the typed model with Delegate set, both directly and through ITransactionCommon

  • Integration suite no longer reaches outside the standalone stand — two places still went over the public internet, so a green build depended on third-party availability:

    • TestIConnectionStates (7 tests) pointed at the public testnet and devnet. Nothing in them is specific to a public network — every assertion is about the client's own state machine — so they now run against the local node. The bogus-hostname case that tested reconnect exhaustion used a DNS lookup; it now uses a closed loopback port, which refuses immediately and involves no resolver. Fixed Task.Delay sleeps were the other half of the flakiness (one of these tests failed a full run and passed on retry) and are replaced by waiting for the expected state with a timeout: the class went from ~40 s of sleeping to sub-second assertions
    • the x402 live t54 interop tests need the public testnet faucet and a hosted third-party facilitator. They are now [TestCategory("Live")] and excluded from CI (--filter "TestI&TestCategory!=Live"), leaving the six hermetic x402 E2E tests in the run. Invoke them deliberately with --filter "TestCategory=Live"
  • Review pass (PR #68):

    • ValidateCheckCreate now enforces InvoiceID as a Hash256, not merely as a string. sfInvoiceID is a 256-bit hash and this same release added exactly that rule for WalletLocator in AccountSet and SignerListSet, so CheckCreate was the odd one out: a malformed value passed validation and only blew up later inside the codec, reporting an encoding error instead of a ValidationException. The exception message is unchanged (CheckCreate: invalid InvoiceID)
    • the CI stand publishes every port on loopback only, matching the nightly stand. The review flagged the new credential-protected ws port (6007) for being bound to every interface, but that was the least exposed of the four: rippled.cfg sets admin = 0.0.0.0 on every stanza, so 5005/5006/6006 hand the admin role — stop, connect, feature, validation_seed, i.e. node control — to anyone who can reach them, and 6007 is the only one that asks for credentials at all. Nothing needed the wider binding: tests connect over localhost and the ledger-acceptor reaches the node through the compose network. Worth doing even though the node is a throwaway genesis container, because a host firewall does not cover this — Docker's DNAT rules sit ahead of the chains ufw manages
    • TestIConnectionStates — both reconnect-exhaustion tests discarded the Task.WhenAny winner, so a run where the terminal event never arrived proceeded after the 30 s timeout and could still pass. They now assert the event task won, and that at least one reconnect was attempted
    • TestIProtocolFieldSets sets Expiration on the mint-time NFT offer but never checked it read back; asserted now, closing the last unverified field of the corrected NFTokenMint set
    • test-only tidying: the parse-floor literal is shared instead of duplicated (RippledTransactionFormats.MinimumExpectedTransactions), the common-field set both conformance surfaces subtract now comes from one helper (RippledTransactionFormats.CommonFields), and a redundant Link on the vendored fixture is dropped

10.9.1.0 27/07/2026

  • Fix account_tx losing the payment amount and, on API v1, the whole transaction — a silent regression introduced by the 10.3.0.0 Newtonsoft.JsonSystem.Text.Json migration; affects every release from 10.3.0.0 on:

    • Payment/PaymentResponse.DeliverMax — the private set-only alias that maps API v2's DeliverMax onto Amount was carried over from Newtonsoft (which deserializes attributed non-public members) but System.Text.Json skips non-public members without [JsonInclude]. Every Payment read through AccountTransactions, TxV2 or the transaction streams came back with Amount = null — no exception, no diagnostic. Tx() was unaffected because it pins ApiVersion = 1, and meta.delivered_amount kept parsing correctly, which is why the loss went unnoticed. The alias stays set-only, so DeliverMax is still never serialized back out
    • TransactionSummary now accepts both envelopes: rippled wraps the transaction in tx_json under API v2 and in tx under API v1 — only tx_json was mapped, so Transaction was null for the entire history whenever ApiVersion = 1 was requested. Hash and LedgerIndex live inside the envelope under API v1 and fall back to it accordingly (previously Hash came back empty, breaking hash-based lookups over the returned list)
    • Regression suite TestUAccountTransactionsEnvelope pins both wire shapes against trimmed captures of real testnet responses — XRP and issued-currency DeliverMax, both envelopes, and the guarantee that DeliverMax never reaches outgoing JSON
  • GetDomainAccess sugar helper — client-side implementation of the domain_access check proposed in XRPLF/rippled#7743: answers whether an account can use a permissioned domain (permissioned DEX, vaults) and why not. One ledger_entry domain lookup plus up to 10 parallel keylet ledger_entry credential lookups, all pinned to the same validated ledger; result mirrors the proposed API (HasAccess + InvalidCredentials with Accepted/Expired diagnostics, empty list = no matching credential). Semantics match rippled credentials::validDomain/checkExpired: lsfAccepted required, expired only when close time is strictly past Expiration, no owner shortcut, client-side expiry check (rippled deletes expired credentials lazily)

10.9.0.0 16/07/2026

  • Unified hex helpers (#40) — seven overlapping implementations consolidated into two canonical utilities; breaking removals (no [Obsolete] grace period):
    • Canonical byte-level pair: Xrpl.AddressCodec.Utils.ToHex(byte[]) / FromHex(string) (renamed from FromBytesToHex/FromHexToBytes); canonical string-level: Xrpl.Utils.StringConversion (+Xrpl.Models.Utils.HexStringHelper for validated/padded VL fields)
    • Removed: the global-namespace ExtensionHelpers class from Xrpl.AddressCodec (leaked ToHex/FromHex into every consumer's scope), the byte-identical Xrpl.Client.Extensions.ExtensionHelpers duplicate (the CS0121 ambiguity trap with StringConversion), dead internal copies in Xrpl.Keypairs/Xrpl.BinaryCodec
    • Hex case convention: UPPERCASE everywhere the SDK emits hex in JSON — matching what rippled returns, so SDK-generated hex compares Ordinal-equal against node output. Affected outputs: ConvertStringToHex, CurrencyToHex (Oracle nonstandard currency codes), Oracle Provider/AssetClass/URI (Blob fields per rippled strHex), cross-chain payment memos. AssetPrice keeps rippled's lowercase UInt64 emission. Transaction bytes, signatures and hashes are unchanged — hex decoding is case-insensitive on both sides
    • HexStringHelper.FromHex gains trimTrailingNulls (default true; FromHexString passes false so variable-length fields round-trip bytes exactly)
    • Fix IsHexCurrencyCode: the regex lacked ^…$ anchors — any longer string containing 40 consecutive hex chars passed as a currency code
    • Pinning suite TestUHexHelpers locks the unified behavior (case, null-trim, anchoring, round-trips)

10.8.0.0 14/07/2026

  • Unified signing & submission for sponsored transactions (#43) — the standard Sign/SubmitAndWait now handle XLS-68 end-to-end, no helper choice required:
    • Sign routes by role: a wallet matching tx.Sponsor produces the sponsor co-signature; the submitter path preserves an existing SponsorSignature and guards against a SigningPubKey mismatch. multisign: true is untouched — Signer entries are section-agnostic per rippled STTx::checkMultiSign (identical preimage for tx.Signers and SponsorSignature.Signers), so the role is decided at composition time
    • SignatureComposer.ComposeSignatures (offline, explicit sponsor signers) and client.ComposeSignatures (ledger-driven SignerList routing with ambiguity/unknown-signer errors) assemble a fully signed transaction from partially signed blobs
    • Smart SubmitAndWait: a sponsor wallet finalizes a sponsee-signed transaction (compose, not re-sign) and fails fast when the main signature is missing; a sponsee submitting without SponsorSignature triggers a one-RPC pre-check of the Sponsorship require-sign flags (sponsorPreCheck: false to skip)
    • client.ComposeSignatures validates SignerList quorum by weights for both sections — readable client-side error instead of tefBAD_QUORUM
    • SubmitAndWaitSponsored(tx, sponseeWallet, sponsorWallet) — the both-keys-local flow in one call
    • Sign also routes the LoanSet borrower automatically: a wallet matching tx.Counterparty produces CounterpartySignature (XLS-66) — all three co-signing mechanisms (Batch/Sponsor/Loan) now share the no-helper-choice entry point
    • New SignatureObject model (shared shape of SponsorSignature/CounterpartySignature/BatchSigner); LOSponsorship gains Flags + SponsorshipFlags
    • Full live signing matrix (TestISponsorshipSigningMatrix): single/multisig on each side in every combination, ledger-routed composition, quorum and ambiguous-signer fail-fast, RegularKey submitter — the matrix surfaced and fixed a real preimage nuance: the multisig preimage includes the outer SigningPubKey, so sponsor-side signers of a single-main sponsored tx must sign over the submitter's pubkey (SignMulti now derives the context from the tx shape)
    • Wire-format safety: pre-refactor outputs pinned byte-level with fixed seeds (TestUSigningPinned); all unified flows produce byte-identical blobs; full integration suite 247/247 on the nightly stand with zero skips
  • Batch × co-signing interplay (verified against rippled Batch::preflight): required batch signers now include the inner initiator (Delegate-aware), the inner Counterparty and the inner Sponsor carrying a SponsorSignature marker — so sponsors/borrowers of inner transactions authorize as batch signers through the same standard Sign; the sponsor of the OUTER batch (spfSponsorFee) is routed to a regular SponsorSignature co-signature; ValidateBatch enforces the new rules (no spfSponsorReserve on the outer, no fee sponsorship on inners, no signature material inside inner co-signature markers); live tests: a reserve-sponsored inner TrustSet lands with HighSponsor/LowSponsor set, a fee-sponsored outer batch passes co-signed, and a sponsor authorizing THROUGH ITS SIGNERLIST lands as a nested-multisig BatchSigner.Signers entry (the sponsor-role counterpart of the initiator-role TestBatchMultiAccountsWithInnerMultiSign coverage); ValidateBatch also rejects Loan/Vault inner transactions client-side (rippled kDisabledTxTypestemINVALID_INNER_BATCH) — LoanSet co-signing cannot ride inside a Batch by protocol design
  • Fixes accumulated since 10.7.0: TxFormat interface parity for AMMDeposit.TradingFee, Uint64.FromJson TryGetValue parsing, MPT validators mirror rippled preflight (MutableFlags masks, TransferFee vs confidential-balances rule), LONFTokenPage.NextPageMin doc, gateway_balances integration test rebuilt on the standalone node
  • Release-review pass (PR #48): SignMulti preserves the submitter's SigningPubKey for LoanSet Counterparty multisign parts (the XLS-66 mirror of the sponsor preimage rule); smart SubmitAndWait recognizes a multisigned main signature (Signers) and skips autofill whenever any signature material is present (a co-signature freezes the body); SignatureObject enforces the two protocol shapes (single vs multisig, no empty/mixed forms) and Combine rejects structurally unsigned material; DomainID validation on MPT issuance transactions (64-char hex; non-zero + tfMPTRequireAuth required on Create, zero legal on Set as domain clear — per rippled preflight); Xrpl.BinaryCodec package version bumped to 10.8.0 (the codec changed since 10.7.0); Sponsorship guide corrects SponsorshipTransfer actors (Create/Reassign are submitted by the sponsee) and documents the sponsee-side SponsorshipSet deletion via CounterpartySponsor; ConfidentialMPT guide describes the integration test accurately (plain issuance, generic tem/tec assertion); protocol-watch workflow fails closed on a corrupted baseline, marks removed upstream files and skips duplicate notifications via a head_sha marker

10.7.0.0 13/07/2026

  • Protocol-completeness pass driven by a field-level diff against rippled develop (server_definitions @ 8306ac77):
    • definitions.json: add HighSponsor/LowSponsor (XLS-68 RippleState reserve sponsors); fix isVLEncoded on Sponsor/Sponsee/CounterpartySponsor (AccountID fields are VL-encoded); align Generic attributes with the node
    • Transaction models: NFTokenMint + Amount/Destination/Expiration (NFTokenMintOffer); MPTokenIssuanceSet + MutableFlags/TransferFee/MPTokenMetadata/DomainID/IssuerEncryptionKey/AuditorEncryptionKey; MPTokenIssuanceCreate + MutableFlags/DomainID; AMMDeposit + TradingFee; LedgerStateFix + BookDirectory; VaultDelete + MemoData; SetFee + XRPFees drops fields
    • Ledger objects: LODirectoryNode + DomainID/ExchangeRate/NFTokenID/TakerPaysMPT/TakerGetsMPT; LORippleState + HighSponsor/LowSponsor; LOAccountRoot + FirstNFTokenSequence/WalletLocator/WalletSize; plus LOAmm, LOEscrow, LOPayChannel, LOSignerList, LOOracle (OracleDocumentID), LONFTokenPage, LOFeeSettings, LODelegate field gaps
    • TxFormat: entries for all four MPT transactions
  • Fix Validation.Validate dispatch: NFTokenModify was routed to ValidateNFTokenMint (a valid Modify without NFTokenTaxon was rejected); now calls ValidateNFTokenModify
  • Fix LOSignerList.SignerListId never being populated: the property lacked a JsonPropertyName attribute and its casing did not match rippled's SignerListID
  • Review pass (PR #34): TxFormat corrections — the entry labeled UNLModify actually held SetFee's legacy format; relabeled to SetFee (all fee fields optional per rippled ttFEE, + XRPFees drops fields), added the real UNLModify and the missing EnableAmendment entries; AMMDeposit + optional TradingFee, VaultDelete + optional MemoData (both verified against rippled develop transactions.macro); MPTokenIssuanceSet gains the MPTokenMetadataRow/Metadata (XLS-89) convenience accessors for parity with MPTokenIssuanceCreate
  • Fix binary-codec JSON encode of UInt64 fields losing field context: a digit-only string for a hex-semantics field (e.g. OwnerNode: "0000000000000012") was parsed as decimal, silently corrupting the value on round-trip. Uint64.FromJson now receives the field's kSmdBaseTen context (decimal for the five base-ten fields, strict hex otherwise) — the decode-side counterpart shipped in 10.6.0
  • Autofill fee: account for sponsor multisig per rippled Transactor::calculateBaseFee — each signer nested in SponsorSignature.Signers adds one base fee (a single-signed SponsorSignature adds nothing)
  • ValidateAccountSet: SetFlag/ClearFlag asf-range checks extracted into a shared helper
  • Unit tests pinning the new fields (binary round-trips) and the dispatch fix; full integration suite (238 tests) green against xrpld 8306ac77 with all amendments active

10.6.0.0 10/07/2026

  • Sponsored Fees & Reserves (XLS-68, Sponsor amendment) — merged into rippled develop on 07/10/2026 (rippled #7350):
    • New transaction models SponsorshipSet (91) and SponsorshipTransfer (90) with tf-flag enums per rippled TxFlags.h; LOSponsorship ledger object (0x90)
    • Common transaction fields Sponsor and SponsorFlags (SponsorCoverage: spfSponsorFee = 1, spfSponsorReserve = 2) on all transactions
    • Sponsor co-signing: SponsorSigningHelper (V1 automatic / V2 parallel combine / V3 sequential) and XrplWallet.SignAsSponsorSponsorSignature is an inner not-signing STObject over the same preimage as the main signature, mirroring the LoanSet counterparty pattern
  • ConfidentialTransfer — five transaction models: ConfidentialMPTConvert (85), ConfidentialMPTMergeInbox (86), ConfidentialMPTConvertBack (87), ConfidentialMPTSend (88), ConfidentialMPTClawback (89); encrypted amounts/commitments/proofs are opaque hex blobs supplied by an external prover
  • definitions.json sync with rippled develop @ fd2cc6dc: +7 transaction types, +Sponsorship ledger entry, +23 fields (Sponsor set, ConfidentialTransfer set, TakerPaysMPT/TakerGetsMPT, ReferenceHolding, SponsorFlags), +8 result codes (temBAD_MPT, temBAD_CIPHERTEXT, tefNO_DST_PARTIAL, tefBAD_PATH_COUNT, terLOCKED, terNO_PERMISSION, tecBAD_PROOF, tecNO_SPONSOR_PERMISSION); TYPES renamed UInt384/UInt512Hash384/Hash512 (ordinals unchanged)
  • TxFormat: common optional fields Delegate, Sponsor, SponsorFlags, SponsorSignature; formats for all 7 new transaction types
  • Integration: TestISponsorship gated by AmendmentGuard (Sponsor/ConfidentialTransfer amendment ids added); nightly stand pinned to xrpld 3.3.0-b1 @ 8306ac77 with Sponsor/ConfidentialTransfer enabled at genesis; all sponsorship integration tests pass against it (ledger-object round-trip, sponsored payment with SponsorSignature accepted as tesSUCCESS, tfDeleteObject)
  • Unit tests: sponsor co-signing across all three flows with cryptographic verification over the shared preimage; SponsorSignature excluded from the preimage (kNotSigning) but round-trips through the binary codec
  • Completeness pass over touched ledger objects: LOAccountRoot gains the XLS-68 counters (SponsoredOwnerCount, SponsoringOwnerCount, SponsoringAccountCount) plus previously missing VaultID/LoanBrokerID back-references; LOMPToken gains the six ConfidentialTransfer balance/key fields; LOMPTokenIssuance gains DomainID, MutableFlags, ReferenceHolding, IssuerEncryptionKey, AuditorEncryptionKey, ConfidentialOutstandingAmount (+11 ledger-object fields added to definitions.json)
  • Fix binary-codec JSON decode of base-ten UInt64 fields (MPTAmount, LockedAmount, OutstandingAmount, MaximumAmount, ConfidentialOutstandingAmount): Decode now emits decimal strings matching rippled (kSmdBaseTen) instead of 16-digit hex — pre-existing gap surfaced by the new round-trip tests
  • Tests: binary round-trips for all five ConfidentialMPT transactions and SponsorshipSet; validation tests mirroring rippled preflight; TestIConfidentialMPT negative e2e (bogus proof is rejected by ConfidentialTransfer domain logic, not the parser — proving the node parses our encoding)

10.5.1.0 04/07/2026

  • Fix SignAsBatchPart with TicketSequence: when the outer Batch used a ticket and had no Sequence, the value 0 was applied only to the signing preimage while the serialized blob omitted the required Sequence: 0 field, producing a malformed transaction on submit. The field is now written into the transaction as well; signatures are unaffected (the preimage already used 0). Found by review on the 10.5.0.0 release PR
  • Add a unit test covering the TicketSequence-present / Sequence-absent signing path (blob carries Sequence: 0, signature verifies over the zero-sequence preimage)
  • Correct the EncodeForSigningBatch XML doc: outerAccount accepts a classic base58 r-address only (the 40-char hex form was never supported by this overload)
  • Harden the nightly amendment stand: admin RPC/WS ports (5005/5006/6006) in docker-compose.batchv11.yml are now published to 127.0.0.1 only

10.5.0.0 03/07/2026

  • BREAKING: Align Batch (XLS-56) signing with the BatchV1_1 amendment (rippled #6446, merged into develop 07/01/2026). The signing preimage now includes the outer Account (20 bytes) and outer Sequence (4 bytes) after the BCH\0 prefix; NetworkID is removed from the preimage. XrplBinaryCodec.EncodeForSigningBatch signature changed to (string outerAccount, uint outerSequence, uint flags, IEnumerable<string> txIDs). Signatures produced by the previous format are rejected by rippled once BatchV1_1 is active
  • SignAsBatchPart single-sig now binds the signature to the BatchSigner account id (finishMultiSigningData equivalent); inner multisign binds owner(20) + signer(20) account ids — both per the audit hardening in BatchV1_1
  • Reject duplicate BatchSigner accounts locally (SortBatchSigners, ValidateBatch) and a BatchSigner equal to the outer Account — early fail instead of temBAD_SIGNER from the server
  • BREAKING: Align DelegateSet (XLS-75) with the PermissionDelegationV1_1 amendment — the delegate account field is Authorize (sfAuthorize), not Delegate: IDelegateSet.Delegate/DelegateSet.Delegate/LODelegate.Delegate renamed to Authorize; TxFormat requires Authorize
  • Add PermissionValueConverter — rippled returns Permission.PermissionValue as a name string in JSON responses (a transaction type name or a granular permission like TrustlineAuthorize); the converter maps names to numeric values (transaction type code + 1; granular table 65537–65548 per permissions.macro) and accepts plain numbers
  • Re-enable TestIBatch (19 tests) and TestIDelegateSet (2 tests) — previously [Ignore]d. New AmendmentGuard marks amendment-dependent integration tests inconclusive (skipped) when the node lacks the amendment, so CI on release images stays green and the tests run for real on a develop node
  • Add a nightly-develop standalone stand for unreleased amendments: .ci-config/Dockerfile.nightly (pinned xrpld nightly from repos.ripple.com), .ci-config/docker-compose.batchv11.yml, .ci-config/rippled.batchv11.cfg (genesis up-votes via the [amendments] section — on rippled develop the [features] section no longer activates amendments in standalone)
  • Add unit tests for the BatchV1_1 preimage layout and both signing modes with cryptographic verification, including negative checks that pre-V1_1-format signatures no longer verify
  • Verified end-to-end against xrpld 3.3.0-b0 (develop, commit c92285f1) with BatchV1_1 and PermissionDelegationV1_1 active: 21/21 integration tests pass; on the 3.2.0 CI image the full TestI suite runs 213 passed / 21 skipped / 0 failed

Xrpl.X402 1.0.0 / Xrpl.X402.AspNetCore 1.0.0 06/23/2026

  • New package Xrpl.X402 — x402 (HTTP-402) agentic payments client for the XRP Ledger (t54 "XRPL exact scheme"). A DelegatingHandler that detects a 402 challenge, builds and locally signs an XRPL Payment (XRP or RLUSD/IOU), and retries with a PAYMENT-SIGNATURE header. Signs but does not submit — the facilitator settles
  • Security: spending caps enforced before signing (XRP MaxAmountDrops; IOU fails closed without an explicit per-issuer cap), optional payTo/issuer allowlist, anti-double-pay, LastLedgerSequence capped by maxTimeoutSeconds
  • Intent binding matches the t54 reference payer: Payment.InvoiceID = SHA-256(invoiceId), a MemoData = hex(invoiceId), payload.invoiceId, and SourceTag from extra.sourceTag (configurable via X402IntentBinding); IOU payments include SendMax
  • Verifiable Intent passthrough via IVerifiableIntentProvider (the SD-JWT chain itself is supplied by the caller)
  • New package Xrpl.X402.AspNetCore — ASP.NET Core server middleware: a RequirePayment endpoint filter plus LedgerSettlingFacilitator (settles locally) and T54Facilitator (delegates to a t54 facilitator)
  • Live interop with the t54 testnet facilitator confirmed on-chain for both XRP and RLUSD/IOU (/verifyisValid:true, /settle settles)

10.4.2.0 05/06/2026

  • Fix thread-unsafe request id assignment in RequestManager — concurrent requests on a single connection (e.g. Task.WhenAll over several BookOffers) could collide on the same id and throw Response with id '$<guid>' is already pending or drop a pending promise. Removed the shared nextId field; each call now generates its own Guid and registers via a single atomic ConcurrentDictionary.TryAdd, enabling parallel requests on one connection
  • Surface exceptions thrown by stream handlers (OnLedgerClosed, OnTransaction, etc.) through the OnError event instead of swallowing them into a debug trace — consumer bugs are now observable, while the message loop stays alive and a throwing OnError handler is contained
  • Clarify in XML docs that Xrpl.Client.Exceptions.TimeoutException is not System.TimeoutException (it derives from XrplException), to avoid mismatched catch clauses

10.4.1.0 28/05/2026

  • Fix IouValue (IOU token amount) parsing to accept a trailing decimal point (e.g. "128700."), aligning with xrpl.js / ripple-binary-codec and rippled STAmount reference behavior — previously the stricter validation regex rejected a value with no digits after the dot, breaking signing of transactions (e.g. AMMDeposit via WalletConnect) that carried such amounts
  • Relax IOU value regex fractional group from (\.(\d+))? to (\.(\d*))? while adding a (?=\.?\d) lookahead that still requires at least one mantissa digit — so trailing/leading dots ("128700.", ".5") parse but bare-dot inputs (".", ".e10") are rejected, matching BigNumber; deduplicate the regex by reusing the single IouValue.ValueRegex constant in AmountValue.cs and ExtenstionHelpers.cs
  • Native XRP (drops) and MPT amount parsing unchanged; mantissa/exponent math, ToString() output, and ToBytes() round-trip preserved bit-for-bit for already-valid values
  • Add unit tests verifying "128700." and "1." parse identically to their dot-less forms (same mantissa/exponent/precision and ToBytes() blob) and regression tests for existing values

10.4.0.0 13/05/2026

  • Sync Xrpl.BinaryCodec enums with upstream definitions.json from xrpl.js
  • Add 24 missing TransactionType entries: XChain (8), Vault (6), Loan (9), LedgerStateFix, DelegateSet, Batch, NFTokenModify, PermissionedDomainSet/Delete, CredentialCreate/Accept/Delete, MPToken (4), DID (2), Oracle (2), AMMClawback
  • Add 16 missing LedgerEntryType entries: Bridge, XChainOwnedClaimID, XChainOwnedCreateAccountClaimID, MPTokenIssuance, MPToken, Oracle, Credential, PermissionedDomain, Delegate, Vault, LoanBroker, Loan, DID, NegativeUNL, NFTokenOffer, NFTokenPage
  • Add 7 missing FieldType entries: Number, Int32, Int64, UInt96, UInt384, UInt512, XChainBridge
  • Add ~40 missing Field entries across all types; fix incorrect ordinals for DiscountedFee, VoteWeight, HookGrants
  • Regenerate EngineResult with all 189 transaction result codes from protocol spec
  • Add terNO_DELEGATE_PERMISSION (-85) to definitions.json
  • Mark deprecated entries with [Obsolete]: HookSet, GeneratorMap, Contract, EnabledAmendments
  • Refactor EngineResult, TransactionType, LedgerEntryType to partial-class architecture — hand-written infrastructure + auto-generated fields from definitions.json
  • Add Tools/GenerateEnums — .NET console tool for regenerating enum files from definitions.json (dotnet run --project Tools/GenerateEnums)
  • XChain Bridge (XLS-38d): Add 8 transaction models, 3 ledger objects (LOBridge, LOXChainOwnedClaimID, LOXChainOwnedCreateAccountClaimID), XChainBridgeModel, attestation models, and integration tests
  • Vault (XLS-65d): Add 6 transaction models (VaultCreate, VaultSet, VaultDelete, VaultDeposit, VaultWithdraw, VaultClawback), LOVault ledger object, and integration tests
  • Lending Protocol (XLS-66d): Add 9 transaction models (LoanBrokerSet, LoanBrokerDelete, LoanBrokerCoverDeposit, LoanBrokerCoverWithdraw, LoanBrokerCoverClawback, LoanSet, LoanDelete, LoanManage, LoanPay), LOLoan and LOLoanBroker ledger objects, and integration tests
  • DelegateSet (XLS-74d): Add DelegateSet transaction model, LODelegate ledger object, and integration tests
  • LedgerStateFix: Add LedgerStateFix transaction model and integration tests
  • Fix NumberType serialization — rewrite from 8-byte raw ulong to 12-byte format (8-byte int64 mantissa + 4-byte int32 exponent) matching rippled Number class. Normalizes mantissa to [10^18, long.MaxValue]
  • Add CounterpartySignature co-signing support for LoanSet — both broker and borrower sign the same preimage
  • Add TxFormat entries and validation for all 25 new transaction types
  • Add converter mappings for all new transaction and ledger entry types
  • Add LendingProtocol-Guide.md and LendingProtocol-Guide.ru.md documentation

10.3.0.0 05/05/2026

  • BREAKING: Migrate entire solution from Newtonsoft.Json to System.Text.Json — all models, converters, client infrastructure, wallet signing, binary codec
  • BREAKING: Remove dynamic keyword from all production code — replace with object, JsonNode, JsonElement for iOS Full AOT compatibility
  • BREAKING: Remove Newtonsoft.Json NuGet dependency from all projects (Xrpl, Xrpl.BinaryCodec, Xrpl.AddressCodec, Xrpl.Keypairs)
  • Add centralized XrplJsonOptions.Default with all custom converters registered globally
  • Add new converters: DictionaryObjectConverter, EnumMemberValueConverter<T>, NumberOrStringConverter, ScientificDecimalConverter, TransactionTypeConverter, LedgerEntryTypeConverter
  • Migrate all [JsonProperty][JsonPropertyName], [JsonIgnore]System.Text.Json.Serialization.JsonIgnore
  • Migrate all JObject/JToken/JArrayJsonNode/JsonObject/JsonArray in wallet signing, batch transactions, signer utilities
  • Migrate all JsonConvert.SerializeObject/DeserializeObjectJsonSerializer.Serialize/Deserialize
  • Add ITransactionRequest.ToDictionary() helper for safe System.Text.Json round-trip in tests
  • Fix SerializedType.ToJson() return type — objectJsonNode to match ISerializedType contract
  • Fix ServerFeatures.FeatureInfo.Count[JsonPropertyName("count")] was inside XML doc comment, not applied to property
  • Fix ChannelAuthorize.RippleAmount setter — Convert.ToUInt32Convert.ToUInt64 to prevent overflow at > 4294 XRP
  • Fix AccountingStateInfo.Durationduration_us field was parsed as milliseconds instead of microseconds (1000x inflation)
  • Fix LedgerTransaction.CloseTimeIso and LOLedger.CloseTimeIso — add FromStringDateTimeConverter for consistent ISO 8601 parsing
  • Fix CredentialQuery.CredentialType wire field — credentialTypecredential_type
  • Fix Amount.FromJson XRP branch — add null/type validation on value property to prevent NullReferenceException
  • Fix AccountId.FromJson — explicit null check to prevent DecodeAccountID(null) crash
  • Fix Uint64 parsing — validate hex length after 0x prefix to reject oversized inputs
  • Fix AssetPriceConverter.Write — reject negative int/long values instead of silent ulong underflow
  • Fix OracleCurrencyConverter.Write — reject currency codes > 20 ASCII bytes instead of silent truncation
  • Fix OracleHexStringConverter.Write — remove content-sniffing that misidentified plain text as pre-encoded hex
  • Fix LOOracle — add missing OracleHexStringConverter on Provider, AssetClass, URI properties (matching OracleSet)
  • Fix XrplBinaryCodec.EncodeForSigningClaim — add null checks on channel and amount properties
  • Fix SimulateRequest.Transaction — add explicit TransactionRequestConverter attribute for reliable polymorphic serialization
  • Fix LedgerObjectConverter — extract shared GetTypeForLedgerEntry() helper, eliminating duplicated 23-type switch
  • Fix ScientificDecimalConverter — parse raw token text via decimal.Parse instead of lossy double cast
  • Fix EnumMemberValueConverter — remove permissive Enum.TryParse fallback that accepted numeric strings

10.2.0.0 03/05/2026

  • Add path_find WebSocket command — PathFind(create), PathFindClose, PathFindStatus methods with PathFindCreateRequest, PathFindCloseRequest, PathFindStatusRequest models and PathFindResponse
  • Add ripple_path_find command — RipplePathFind method with RipplePathFindRequest, RipplePathFindResponse, SourceCurrency models
  • Add PathAlternative shared model with PathsComputed, PathsCanonical, SourceAmount, DestinationAmount
  • Add Type and TypeHex bitmask fields to Path model for path step type identification
  • Fix PathFindStream — change DestinationAmount/SendMax from decimal to Currency, change Id from Guid? to object, replace AlternativePath with shared PathAlternative
  • Fix message routing for path_find async follow-ups — RequestManager.HandleResponse now returns (Response, Handled) tuple, unhandled messages with id are routed to stream processing
  • Add TestEmitsPathFind unit test with two sequential stream messages validation
  • Add integration tests for path_find (create/close/status/stream) and ripple_path_find (basic/with source currencies)
  • Add ParseMPTID utility for MPTokenIssuanceID (XLS-33) encoding/decoding — GenerateMPTokenIssuanceID(sequence, issuer) and string.ParseMPTokenIssuanceID() extension
  • Add MPTokenIssuanceIdData model mirroring NFTokenIdData pattern (Sequence, Issuer, computed MPTokenIssuanceID)
  • Add computed MPTokenIssuanceID property to LOMPTokenIssuance derived from Sequence + Issuer
  • XLS-70 Credentials: full parity with xrpl.js
    • Add deposit_authorized request/response models (DepositAuthorizedRequest, DepositAuthorized) with optional XLS-70 credentials parameter
    • Implement IXrplClient.DepositAuthorized(request, ct) method
    • Add CredentialIDs (Vector256, optional) field to Payment, EscrowFinish, AccountDelete, PaymentChannelClaim models, validation and TxFormat
    • Extend DepositPreauth transaction with AuthorizeCredentials / UnauthorizeCredentials arrays and rewrite validation to enforce mutual exclusivity of Authorize/Unauthorize/AuthorizeCredentials/UnauthorizeCredentials
    • Fix broken TxFormat[DepositPreauth] (replaced PaymentChannelClaim fields with correct DepositPreauth fields including credential arrays)
    • Add shared CredentialsValidator.ValidateCredentialsList helper supporting both hex object IDs and wrapped { Credential: { Issuer, CredentialType } } objects (max 8, hex format, no duplicates)
    • Fix binary codec: place CredentialIDs at Vector256 nth=5 and move HookNamespaces to nth=32 per rippled spec
    • Add LedgerSpace.Credential = 'D' and Hashes.HashCredential(subject, issuer, credentialType) helper to compute Credential ledger entry object IDs (SHA512Half)
    • Add unit tests for CredentialsValidator, extended DepositPreauth validation, and CredentialIDs validation across all four affected transactions
    • Add integration tests for deposit_authorized (with/without credentials) and end-to-end XLS-70 scenario: CredentialCreateCredentialAcceptAccountSet(asfDepositAuth)DepositPreauth(AuthorizeCredentials)Payment(CredentialIDs)

10.1.6.0 15/04/2026

  • Fix for Currency to HEX for currency with 1 or 2 symbol in name

10.1.5.0 14/04/2026

  • Fix binary codec field codes for AMM Amount fields — LPTokenOut (20→25), LPTokenIn (21→26), EPrice (22→27), Price (23→28), LPTokenBalance (24→31)
  • Add missing binary codec Amount field definitions: BaseFeeDrops (22), ReserveBaseDrops (23), ReserveIncrementDrops (24), SignatureReward (29), MinAccountCreateAmount (30)
  • Add AMM lifecycle integration tests (16 tests): AMMCreate, AMMDeposit (SingleAsset, TwoAssets, LPToken), AMMWithdraw (LPToken, WithdrawAll, FullLP precision regression, SingleAsset, Simulate+Submit, TypedModel), AMMDelete (EmptyPool, NonEmptyPool, AfterPartialWithdraw), AMMVote

10.1.4.0 14/04/2026

  • Fix Currency.ValueAsNumber setter precision — change format from "G15" to "G16" to preserve all 16 significant digits of XRPL token mantissa, preventing tecAMM_INVALID_TOKENS on full LP token withdrawal due to rounding up
  • Add unit tests for Currency class — round-trip precision, ValueAsXrp, implicit operators, CurrencyExtensions, equality operators (39 tests)

10.1.3.0 11/04/2026

  • Add deep_freeze and deep_freeze_peer fields to TrustLine model (XLS-77 Deep Freeze support)
  • Add Limit field to AccountLines response
  • Change AccountLinesRequest.IgnoreDefault type from bool to bool?
  • Add PseudoAccount field to AccountInfo response
  • Add AMMID field to LOAccountRoot

10.1.2.0 05/04/2026

  • Fix WaitForFinalTransactionOutcometxnNotFound was never recognized due to reading empty Exception.Data instead of RippledException.Response.Error, causing false ValidationException on successful submissions
  • Replace generic catch (Exception) in WaitForFinalTransactionOutcome with split catch blocks: RippledException with when filter for txnNotFound, re-throw for other rippled errors, XrplException wrapper for unexpected errors
  • Add null-safety for Response in XrplErrorClassifier.Classify(RippledException)

10.1.1.0 05/04/2026

  • Add new ripple state flags support

10.1.0.1 03/04/2026

  • Convert XrplErrorClassifier methods to extension methods for fluent error classification (exception.Classify())
  • Add try-catch around response deserialization in RequestManager.Resolve — reject promise and rethrow on failure
  • Integrate XrplErrorClassifier into Connection.IOnMessageFastPath error handler with user-friendly error messages
  • Change Submit/SubmitAndWait autofill default from false to true
  • Add AllowTrustLineLocking flag to AccountInfoAccountFlags
  • Fix NoRippleCheck Transactions deserialization — use List<ITransactionRequest> with polymorphic TransactionRequestConverter
  • Fix CurrencyConverter to handle JsonToken.Integer for XRP amounts

10.1.0.0 02/04/2026

  • Add optional CancellationToken support for all client requests (IXrplClient, Connection, RequestManager)
  • Thread CancellationToken through all Sugar methods (Autofill, Submit, Balances, GetOrderBook, GetFeeXrp, GetLedgerIndex)
  • Make RequestManager.Resolve idempotent — no longer throws when promise is already cancelled/timed out
  • Add safe async dispose of CancellationTokenRegistration to prevent deadlocks in cancellation callbacks
  • Add 9 unit and E2E tests for CancellationToken (cancellation, race conditions, timeout priority, connection isolation)
  • Full backward compatibility — all CancellationToken parameters are optional with default value

10.0.2.1 30/03/2026

  • Fix polymorphic ledger entry deserialization for account_objects
  • Fix ledger_data JSON response mapping for state
  • Add missing ledger, validated, and ledger entry type filter support

10.0.2 25/03/2026

  • Add XRPL error classifier with normalized XrplErrorInfo
  • Add structured XRPL error metadata: category, subject, retryable/user-fixable flags, command, field, and warnings
  • Add tests and documentation for XRPL error classification
  • Minor RequestManager cleanup for pending response handling

10.0.1.1 24/03/2026

  • Fix ErrorResponse
  • Fix RippledException when error in response

10.0.1 20/03/2026

  • Refactor gateway_balances request
  • Add v1 transaction response support
  • Fix test account builder
  • Refactor metadata with converters for ledger types
  • Add missing ledger entry request parameters
  • Add wallet FromPrivateKey method
  • Fix LedgerObject date conversion
  • Add mnemonic verification

10.0.0.1-mptmeta 02/13/2026

  • MPToken Metadata parser

10.0.0

  • Upgrade to .NET 10.0
  • TokenEscrow (XLS-85) — extended escrow support for fungible tokens (IOU/MPT)
  • Credentials (XLS-70) — CredentialCreate, CredentialAccept, CredentialDelete transactions, LOCredential ledger entry
  • PermissionedDomain (XLS-80) — PermissionedDomainSet, PermissionedDomainDelete transactions, LOPermissionedDomain ledger entry
  • Permissioned DEX (XLS-81) — DomainID and tfHybrid flag for OfferCreate, DomainID for Payment

9.8.3-implicit 02/11/2026

  • Add Currency uint implicit conversion

9.8.2-apiVersion 02/09/2026

  • Fix API version set

9.8.1-connection 02/06/2026

  • Connection stabilization improvements
  • Minor config fix
  • Documentation updates

9.8.0 02/04/2026

  • Mnemonic wallet generator
  • Xumm numbers generator
  • Connection stabilization and errored tasks resolution
  • Update account flags and clear flags fix
  • Add test data init
  • Fix connection issues

9.7.2 01/24/2026

  • Fix race condition null exception in DID handling

9.7.1 01/24/2026

  • Add JSON writer for converters (DID fix)

9.7.0 01/22/2026

  • Add DID (Decentralized Identifier) support — DIDSet, DIDDelete transactions
  • Add Clawback transaction support
  • Add AMMClawback transaction support
  • Add Oracle Set/Delete transactions (XLS-47 Price Feeds)

9.6.2 01/17/2026

  • Add signer locator (WalletLocator) encoding
  • Update connection logic
  • Fix encoding issues
  • Documentation updates

9.6.1 12/16/2025

  • Add connection status tracking
  • Fix namespace for BalanceChanges

9.6.0 12/15/2025

  • Add MPToken support (MPTokenAuthorize, MPTokenIssuanceCreate, MPTokenIssuanceDestroy, MPTokenIssuanceSet)
  • Add currency extensions
  • Add features request

9.5.0 12/13/2025

  • Signing refactoring — batch signing, in-batch multisign
  • Refactor autofill logic
  • Refactor TX common models
  • Fix encoding and sign model issues
  • Add sign batch tests

9.4.1 12/01/2025

  • Add Pbkdf2 for wallet from text

9.4.0 11/18/2025

  • Upgrade to .NET 9
  • Add RequestFailurePolicy and status wait for connection
  • Add reconnection stop flag and timeout for connection
  • Fix on user disconnect and ping policy
  • LastLedgerSequence can be null
  • Refactoring and test fixes

9.3.0 11/12/2025

  • Connection manager fix — auto-reconnect, connection ping-pong, reconnection progress

9.2.1 11/10/2025

  • Fix Payment deliverMax serialization

9.2.0 11/10/2025

  • Add deliverMax support
  • Add warning notifications
  • NFT parse update

9.1.5 11/09/2025

  • Add destination interface

9.1.4 11/09/2025

  • Fix ledger response

9.1.3 11/02/2025

  • Fix WebAssembly (WASM) support error
  • Add Blazor test app

9.1.2 10/16/2025

  • Fix autofill fee calculation

9.1.1 10/14/2025

  • Add ledger entry types
  • Fix serialization error

9.1.0 10/14/2025

  • Add Batch transaction support with multi-signature
  • Add wallet from any text
  • Add simulate request
  • Add batch enum to base enums
  • Fix flag references for in-batch TX serialization
  • Update AccountInfo and AccountObjects
  • Minor fixes and optimization

9.0.8 06/29/2025

  • Add XLS-46d (dynamic NFTs) transaction support
  • Fix AMM Withdraw flags
  • Fix client issues

9.0.7 06/01/2025

  • Fix NFTokenIds

9.0.6-beta 05/26/2025

  • Fix Submit and wait logic
  • Add TxV2 request/response

9.0.3-beta 05/24/2025

  • Refactoring for API v2 — stream custom converter
  • Add BalanceChanges
  • Add Book equals and AMM deposit flag
  • Fix response ID format for re-using
  • Fix ledger entry response
  • Update client and packages
  • Fix v2 adaptation and unsubscribe
  • netstandard optimization and currency extensions
  • Fix AMM TX encoding
  • Add mnemonic support

1.0.6 06/19/2022

  • Fix Trustlines JsonProperty and Limit default (thanks @ReneBrauwers)

1.0.5 06/09/2022

  • Add payment channel encoding

1.0.3 05/26/2022

  • Update XLS-20 fields

1.0.2 03/31/2022

  • Fix tests and initial setup

1.0.0 04/30/2023

  • Initial Release of XrplCSharp