-
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.jsonhad 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 inheritsbash -efrom 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):
VaultCreateandLOVaultcarryVaultKind,SubscriptionDateandRedemptionDate, and theVaultKindenum names the two kinds.ValidateVaultCreatepins 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):
LOMPTokenIssuancecarriesIssuerKeyEpochandAuditorKeyEpoch, incremented each timeMPTokenIssuanceSetreplaces the key. The transaction is unchanged - the sameIssuerEncryptionKey/AuditorEncryptionKeyfields rotate a key once the amendment is active, and the current key is refused withtecDUPLICATE.LOMPTokencarries the holder side,IssuerKeyMirrorEpochandAuditorKeyMirrorEpoch: 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.macromoves to developf6b51f0b, the commit that placed the mirror epochs onMPToken.TestULedgerEntryFieldsConformancereads that file in both directions, so moving the pin also named the two Smart Escrow entries it carries, and the models follow them:LOEscrowgainsBytecodeandData,LOFeeSettingsthe votedGasLimit,BytecodeSizeLimitandGasPrice. Ledger-object fields only - theSmartEscrowamendment isSupported::Noon every build, so nothing returns them yet and the transaction side stays out of this release VaultWithdrawandLoanBrokerCoverWithdrawacceptCredentialIDs, for aDestinationthat requires deposit authorization; validated the wayPayment.CredentialIDsis- a protocol field is a member on the transaction's interface as well as on its classes, so
IVaultCreate,IVaultWithdrawandILoanBrokerCoverWithdraweach 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 unlikeIXrplClientnothing implements them to add behaviour - the vendored
transactions.macrois pinned to the same develop commit asledger_entries.macroinstead 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.BinaryCodec11.6.0.0 for the new codec entries, numbered withXrplsince 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, andTestIClosedEndedVaultdrives 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 noVaultKindon the ledger, and aVaultWithdrawto a deposit-authorized destination that istecNO_PERMISSIONwithoutCredentialIDsand 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
LoanBrokerSetrefuses an open-ended vault ("LoanBroker requires a closed-ended Vault",tecNO_PERMISSION) and every Loan test built its broker on one, so all 18 ofTestILoanfailed on the nightly stand - identically on untoucheddev, which is what said it was the node's rule rather than a regression.TestILoanBasenow 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 answersinvalidTransaction, not a result code - so the open-ended path stays for it, chosen throughAmendmentGuard - what that unblocks, and what it does not: on the nightly stand
TestILoangoes from 0 of 18 to 7 of 18, and all 11 that still failed reportedCounterparty: Invalid signature- the role signing prefixesfixCleanup3_4_0introduces, which is what the entry above this one goes on to implement, and which was the whole of what remained.TestISponsoredVaultLoanis blocked by the same thing on 3.4.x, atSponsor: 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 ValidatedCloseTimeAsyncandWaitForCloseTimeAsyncmoved toIntegrationTestConfig: three test classes now need the ledger clock, and each had been carrying its own copy
- closed-ended vaults (rippled #7921, LendingProtocolV1_1):
-
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'sTxnSignature, the sponsor'sSponsorSignature(XLS-68) and the borrower'sCounterpartySignature(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.HashPrefixgainsCounterpartyTransactionSig,CounterpartyTransactionMultiSig,SponsorTransactionSigandSponsorTransactionMultiSig, andEncodeForSigning/EncodeForMultiSigninggain 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
Sponsorsigns as sponsor, a LoanSetCounterpartyas 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.SignersandCounterpartySignature.Signersare 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
SponsororLendingProtocolenabled but notfixCleanup3_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
SponsorandLendingProtocolvoted in at genesis, so the integration tests that produce a role signature now skip there throughAmendmentGuardand 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 - theSponsorshipSettests 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.
ComposeSignaturesverifies 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 aSigningPubKeythe 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.GetSigningPreimageis nowGetSponsorPreimage, and the internal loan oneGetCounterpartyPreimage. 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 standTestILoanpasses 18 of 18 and the sponsorship classes are green, where before this release every one of them was refused withInvalid 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
tfLoanLatePaymentor it istecEXPIRED
- Transaction validation is synchronous, and
BatchUtils.Buildvalidates what it assembles (breaking).Validation.Validateand all 86 per-transaction validators behind it were declaredasync Taskwithout 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.BuildcalledValidation.Validate(...)and discarded the task. Anasyncmethod captures every exception into the task it returns, including the ones thrown before the firstawait, so a discarded task is a discarded verdict:ValidateBatchran, decided the batch was malformed, and reported it to nobody. A Batch built around a single inner transaction - which rippled answers withtemARRAY_EMPTY- came back fromBuildlooking well formed, and so did one with more than eight inners, with aVault/Loaninner, with an inner missingtfInnerBatchTxn, or with an inner carrying a non-zeroFee. The compiler had been saying so since the method was written (CS4014).- the validators now return
voidand throw on the calling thread.await Validation.Validate(tx)no longer compiles: drop theawait. This is the whole migration - the exception type, the message and the conditions are unchanged, and atry/catcharound 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
MissingMethodExceptionon 11.5.1.0 even where it never wroteawait, and its sources need theawaitdropped before they compile again. It is numbered a patch because the validators are opt-in - nothing inside the SDK calls them, andBatchUtils.Build, the one caller that did, is the method this release fixes. If you callValidation.*orCommon.ValidateBaseTransactiondirectly, treat this upgrade as a major one: rebuild, and drop theawait. - making the signature honest is what fixes the defect, rather than adding the missing
await:Buildis synchronous and public, so awaiting would have meantTask<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. TestUCredentialsValidatorwrapped the already-synchronousCredentialsValidator.ValidateCredentialsListinTask.Runpurely to fit the async assertion helper'sFunc<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, sinceActionaccepts a lambda whose value is discarded. The nine tests call the validator directly now.
- the validators now return
- 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.
NotConnectedExceptioncarried five different events andOperationCanceledExceptiontwo, 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
catchclause changes meaning and no task changes status:ClientDisconnectedExceptionandReconnectExhaustedException(attempts spent, budget configured),RequestRefusedExceptionfor a request the caller asked not to have wait,ConnectHandlerFailedException(how many times the handler failed, and the handler's own exception),ConnectionClosedPermanentlyExceptionfor a node that closed with a code this client does not retry after, andNotConnectingExceptionfor a client with no attempt in progress.ConnectionSupersededExceptionderives fromOperationCanceledExceptionand names the transition that took over and where it left the client - a broken
OnConnectedhandler is no longer reported as a disconnect the consumer performed. The give-up path ends by callingDisconnect()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
OperationCanceledExceptionreading "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.StopReasonsays 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.StopReasonsays why the client stopped.Disconnectedis announced from ten places and they differed only in text, so "still trying" against "gave up" was derivable only from the absence ofReconnectInfo- which is also what a client that never had a loop looks like. The reason goes on the notification rather than intoReconnectInfo, soReconnect != nullkeeps its one meaningWaitForConnectionOutcomeAsyncanswers "did it come back?" with a value rather than an exception, onConnection, onXrplClientand onIXrplClient- where the wait was previously unreachable except through the connection object.ConnectionWaitOutcomenames the case rather than folding "timed out", "gave up" and "nothing is running" into onefalse.HasConnectionAsyncis untouched: adding aCancellationTokenoverload 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
ChangeServerreads the network id the wayConnectdoes.Connecthas 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;ChangeServerread it once, directly, so a connection that needed a second attempt failed the switchConnectionManageris 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 asclient.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 insideResolveAllAwaiting, which is called from insideOnceOpenbefore theOnConnectedhandler, 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 areTrySet*, and a cancellation isTrySetCanceledrather than a faulted taskStopAfterMaxAttemptsnow 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 announcedDisconnectedand 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 aConnect()orChangeServerbegins 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
Disconnectedstate - which is also exactly what a client nobody has calledConnect()on looks like: a consumer who heardReconnectExhaustedon the status stream and confirmed it on the wait before failing over was toldNotConnecting, 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 asClosedPermanentlyand 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.ClosedPermanentlyandConnectionClosedPermanentlyExceptionare its counterpart. And the exhaustion the loop records, the permanent close, and the generation all used0for "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
RestoringConnectionafter 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.
ConnectionStopReasonis 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.UserDisconnectedhad been paired with aConnectionWaitOutcome.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 factwillReconnectreads - and that was measured, not argued: the branch was instrumented and the whole suite run twice, once on the reason and once onwillReconnectitself, 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 isReconnectExhausted, 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 brokenOnConnectedhandler, 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/ConnectionLifecycleSampleagainst 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 fastIsConnected()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 onIsConnected()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 aRestoringConnectionfrom 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 -Connectingfrom the takeover that just took it, theDisconnect()paths,Connectedfrom 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
Connectedthat 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.WhenAlldoes 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
- six new exception types, all deriving from the ones thrown today, so no
- 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 anOnConnectedhandler fails - used to decide for itself what happened to the socket, and two of them running at once were reconciled byReferenceEquals(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 ofChangeServer'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 answersRestoringConnectionwith aChangeServerno 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
ChangeServerthat a later operation overtook reports it instead of returning success from a server the client is not on -NotConnectedExceptionwhen aDisconnect()won,OperationCanceledExceptionwhen anotherChangeServeror aConnect()did.Connect()keeps its contract: it returns when the client is connected, wherever a concurrent switch took it, andOperationCanceledExceptionstill means the caller's own token. Options handed toChangeServerare validated before the old connection is torn down rather than after - the two loose ends from #178 are tied.
NotConnectedExceptionthrown bare carries a message that says what it is, and the immediate refusal underRequestFailurePolicy.ImmediateFailnames the policy - since #178 that is the exception a request issued during a switch gets, where it used to get aTimeoutExceptionwith "Timeout" in it, and a consumer classifying by text had nothing to recognise.WebSocketClient.SendMessageno longer answers a socket that is not open with aConnect()-ConnectAsyncon an already usedClientWebSocketthrows, the catch disposed the socket and raisedOnConnectionError, and the send went ahead regardless - andSendMessageAsyncreturns a task that faults when the message could not be written, so the request that owns it is rejected at once rather than left toRequestTimeout. Messages are serialized whole on the socket; two concurrent messages larger than the send chunk could interleave their frames before OnSessionEndedis owed whatever wins. AChangeServeror fast reconnect that aDisconnect()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()announcesUserDisconnecteditself rather than leaving it to the close callback alone: aConnect()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 aConnect()after aDisconnect()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, reportedDisconnectedand 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 aDisconnect()that took a handshake still in flight no longer installs a completion source nobody completes - the cancelled handshake reports no close - so the nextDisconnectAndWaitAsyncreturns 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.
OnceOpenreportedConnectedand started a ping timer nothing would stop after aDisconnect()from inside theOnConnectedhandler.Connect()after aDisconnect()ran with the intentional-disconnect flag still set, so a server that was down read as "closed permanently" and nothing reconnected -ChangeServerwas 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 firingOnConnectionFailedfor the dead socket at everyConnectionAttemptTimeout. AndConnect()over a socket that was closing announced no session end and swept no requests, both of which the close callback would have done hadConnect()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
ClientWebSocketsays 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 anOnDisconnectthat 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 aWebSocketExceptionon 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
ConnectAsyncthrowsWebSocketException("ConnectFailure") rather thanOperationCanceledException, 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 throwsOperationCanceledExceptionand a dropped connection arrives as a network error - the documentation of
UseCheckHealthandInactivityTimeoutpromised 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 withUseCustomPingenabled - 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 secondChangeServerfrom the session-ended handler of aChangeServer, aChangeServerfrom theRestoringConnectionnotification of the fast reconnect, aDisconnect()from theOnConnectedhandler, aConnect()after aDisconnect()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
- 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.
-
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 anOnConnectedhandler 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:RequestManagerbuilds its completion sources withoutRunContinuationsAsynchronously, 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 ImmediateFailnow refuses such a request at once withNotConnectedException;WaitForConnectioncarries 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 forRequestTimeout- for consumers: retry logic that recognised this failure by the
TimeoutExceptionit used to produce now seesNotConnectedException(orOperationCanceledException, 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
- the fix clears the socket reference before the sweep. Moving it ahead of the first
-
ChangeServer, the fast reconnect andDisconnectno 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.
RetireCurrentSessionAndReconnectAsyncruns 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 fullWaitForPingToFinishAsynctimeout before announcing that the session had ended. The wait now recognises the ping it runs in.RestoringConnectiontoOnSessionEndedon 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,
OnceOpenretired the source, and the fast reconnect's wait came back cancelled. Its catch read that as a failure: it reportedRestoringConnectionon a client that was connected and started a second loop, whose first attempt retired the live socket and opened another. Consumers saw twoOnConnectedper recovery, with a spuriousRestoringConnectionbetween 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
- pinned by a test that takes the server down at
-
FundWalletno 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 returnedFundedwith 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 atesresult. 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.
Baselinecarries 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
- the fix is not a better baseline, it is not needing one. The faucet names the payment it sent -
-
xAddressis 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.BalanceandFaucetAccount.Secretwere mapped and were always0andnull: checked against the live devnet and testnet faucets, the response isaccount(xAddress,address,classicAddress),amount,transactionHash, and a top-levelseedonly when nodestinationis given. Both dead properties are removed andTransactionHashis 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.
-
EasyTimeris gone (breaking). The class sat inXrpl.Walletas a pair ofSystem.Timers.Timerwrappers named after JavaScript'ssetIntervalandsetTimeout, because the file around it is a port of xrpl.js'sfundWallet.ts. Nothing ever called it: across every revision ofFundWallet.csback to October 2022 there is not one use, and the faucet poll it was presumably written for drove aSystem.Timers.Timerthrough 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.Timeris 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 -
SetIntervallet callbacks overlap when the work outlasted the interval, and stopping either one depended on the caller holding the returned handle
- 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 -
-
The
Sponsorfield is exercised on the Vault and Loan transaction types, and on the bridge attestations. These are the types rippled forbids inside a Batch (Batch::preflightkDisabledTxTypes), so whether they take a sponsor at all was worth establishing rather than assuming. They do:preflight1SponsorinTransactor.cppconstrains onlyspfSponsorReserve, through the allow-list inisReserveSponsorAllowed, and no Vault or Loan type is on it. Fee sponsorship is unconstrained.- a sponsored
LoanSetcarries three signatures at once - the broker's own, the borrower'sCounterpartySignatureand the sponsor'sSponsorSignature- and this is the first time the composer has had to place all three in one transaction LoanBrokerCoverWithdraw,LoanBrokerCoverClawbackandLoanManagehad 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::preflightthat are easy to trip, now written down where the test lives:VaultIDis required even when the transaction updates a broker that already exists, and a transaction naming aLoanBrokerIDmay not carryManagementFeeRate,CoverRateMinimumorCoverRateLiquidation- those are set once, at creation, and an update carrying one istemINVALID VaultDelete.MemoDatais left out. The field is optional on rippled's develop branch and the release build the CI stand runs answerstemDISABLEDfor it, so a test carrying it would report the stand's version rather than anything about the SDK
- a sponsored
-
FundWalletworks more than once per process. The faucet helper polled for the funded balance through aSystem.Timers.Timerdriven 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 reportedUnable to fund address with faucet after waiting 1 * 20 secondswithout 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 onTask.Delay(...).Wait()inside an async method. It is an ordinaryawaited 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,AssetClassandURI, and for a nonstandard currency code, required the decoded bytes to be printable ASCII and threw aJsonExceptionotherwise. rippled imposes no such rule:OracleSet::preflightchecks 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 aJsonConverter, one such value did not fail one field, it threw out of the whole response - a single third-party oracle in aledger_datapage 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.
TestIMemoLimitshears 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.
DecodeOracleCurrencystopped at the first zero byte without checking that the rest were zero, so5553440001...read asUSDand would have lost the0x01on the way back out, and twenty zero bytes read as an empty string. Both come back as the hex the node sent now. -
SignatureComposer.ComposeSignatureskeeps 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::preflightanswerstemARRAY_EMPTYto fewer than two inners - the same code as for none at all - while the SDK'sValidation.ValidateBatchonly refused an emptyRawTransactions. 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 emptySigningPubKeyand aSignersarray that rippled checks against the counterparty's SignerList over the same multisign preimage astx.Signers(STTx::checkMultiSignwith the inner object) - but nothing in the SDK could produce it. Each signer of the borrower's list now signs with the standardSign(tx, multisign: true), and the composer places the entries:IXrplClient.ComposeSignatureslooks the Counterparty's SignerList up alongside the Account's and the Sponsor's, routes the entries intoCounterpartySignature.Signers, and pre-checks the quorum by weight so a short set fails with a readable message instead oftefBAD_QUORUMSignatureComposer.ComposeSignatures(parts, sponsorSignerAccounts, counterpartySignerAccounts)does the same offline, andLoanSigningHelper.CombineLoanSignatures(parts, counterpartySignerAccounts)is the LoanSet-shaped entry to it- the fee has to cover the signers: rippled
LoanSet::calculateBaseFeecharges one base fee per entry inCounterpartySignature.Signers, so autofill withsignersCountset 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).
XChainAddClaimAttestationandXChainAddAccountCreateAttestationwere modelled but nothing could fill theirPublicKeyandSignature: a witness signs the canonical serialization of an STObject holding the attested facts, with no hash prefix and no transaction fields (rippledAttestationClaim::message/AttestationCreateAccount::message).XChainAttestationSignerbuilds those bytes from the attestation transaction's own fields, signs them with the witness wallet, and verifies a received attestation the wayattestationPreflightdoes.- 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 onSTXChainBridgeor on field ordering fails there rather than astemXCHAIN_BAD_PROOFon 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 withWasLockingChainSend = 0releases them here - delivery on quorum with aDestination, an explicitXChainClaimwith aDestinationTagwithout one, an unlisted witness refused withtecNO_PERMISSION, and account creation reaching quorum across two witnesses through anXChainOwnedCreateAccountClaimID
- the byte layout is pinned field by field from the XRPL binary format, independent of the SDK's codec (
-
The integration suite runs against any node, not only the standalone stand. Every
TestI*class hard-codedTestNodeType.Standaloneand the genesis account for funding;XRPL_TEST_NODEselected nothing. The profile now comes from the environment -XRPL_TEST_NODE(standalone,devnet,testnet) picks the funding policy and whetherledger_acceptis issued,XRPL_TEST_NODE_URLoverrides 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 newdevnet-coverage.ymlworkflow (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); theSponsorfield on every transaction type other than Payment, sponsor co-signing (TestISponsoredTypes); every AMM transaction type over an MPT asset, including thelsfMPTAMMentry flag on the pool account (TestIAMMMpt, formerlyTestIAMMCreateMpt) - a time gate in rippled is
now > mark, notnow >= mark(after()inView.cpp), so a wait that stops on equality is still a tick early. The escrow batch case waited that way and itsEscrowCancelinner came in one close time short, which undertfAllOrNothingreverted the batch and made the siblingEscrowFinishvanish 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
Sponsorfield, sospfSponsorReserveonDIDSetistemINVALID_FLAG; and the Sponsorship entry lands in the sponsee's owner directory too, soasfAllowTrustLineClawbackmust be set before the sponsorship exists - AMM, AMMClawback, MPTokensV1 and XChainBridge classes are gated by
AmendmentGuardlike the others, so they skip on a network without the amendment instead of failing.MPTokensV2is a[features]preset on the standalone stands and invisible to the on-ledger guard, soTestIAMMMptruns 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, andTestIBatchandTestIMultisigndisable 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.TestTransactionverified 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
srcActNotFoundon 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:
TestIAdminCredentialsneeds the stand's own[port_ws_admin_auth], andTestIAccountDeleteforces 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 gottefPAST_SEQ, which is how this surfaced on devnet - an outer Batch validating with
tesSUCCESSdoes not mean its inner transactions applied. UndertfAllOrNothinga 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 becauseBatch::doApplyreturnstesSUCCESSregardless. A caller reading only the outer result cannot tell the two apart.TestIBatchInnerTypessays so when an inner is missing, and its escrow case moved totfIndependent, 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
txnNotFoundfor a short window before they are queryable, and a single attempt madeTestIBatchInnerTypesfail 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
EnsureBalanceAsynctops an account up to a stated minimum instead of assuming one call is enough TestILedgerStateFixsubmitted withfail_hard, which drops atecresult from the open ledger, so itstecFAILED_PROCESSINGnever 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
- 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 (
-
A transaction with no
TransactionTypereads back asUnknowninstead ofAccountSet.TransactionTypeConvertermaps an unrecognised type name toTransactionType.Unknown, but it only runs when the field is there to read. When it is absent,TransactionRequestConverterandTransactionResponseConverterstill built their sentinel object and nothing ever assigned the property, so it keptdefault(TransactionType)- and the enum's first member isAccountSet, notUnknown. The result was a concrete wrong type rather than a missing one: a caller inspectingTransactionTypewas toldAccountSetabout a transaction that never said so. Both sentinels now set the property in their constructor, which also covers an unrecognised name reaching them throughJsonSerializerOptionsassembled withoutXrplJsonOptions.Default.- the enum is left as it is.
AccountSethas held the implicit0since the type was introduced, and reordering to putUnknownthere 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 fromdefinitions.json, not from this type
- the enum is left as it is.
-
The refusals of XLS-38 are covered, and two of them were the SDK's own footguns.
TestIXChainNegativesubmits 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 inXChainAttestationSignerthat 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.Amountcarries the issue of the chain the claim is paid on - the locking chain issuer, not the issuing chain door - and getting that wrong istecXCHAIN_BAD_TRANSFER_ISSUErather than the code under test. AndWasLockingChainSenddoes not only pick a direction:attestationPreflightderives 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_KEYis deliberately absent:checkAttestationPublicKeyanswerstecNO_PERMISSIONin 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
TestIXChainBridgeBaseso both XChain classes build their stand the same way, and it now takes a quorum and a witness count
- two of the eight were written wrong first, and the node said so.
-
A faucet failure says what failed.
ProcessSuccessfulResponsecaught everything and rebuilt it asnew 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:XRPLFaucetExceptionhad only(string), though its baseXrplExceptionhas taken an inner exception all along.- the catch had a branch that could not run -
if (err is Exception)on a variable declaredException, with the real handling in the unreachable half - and it caught theXRPLFaucetExceptionthrown a few lines above inside the sametry, rebuilding it from its own message and resetting its stack. Both are gone OperationCanceledExceptionwas swallowed the same way, so a caller who cancelled was told the faucet had failed. It passes through now, and there is aCancellationTokento 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 andGetXrpBalance- 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 aNullReferenceException. That last case was reachable -JsonSerializer.Deserializehands back null for anullliteral, and the old code went straight tofaucetWallet.Account. Nine tests cover it, the first this file has had that do not need a network
- the catch had a branch that could not run -
-
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 nodestinationis 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 namedsecret,seed,master_seed,private_keyorpassphraseis 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.
ReturnPromisenewed anHttpClientfor 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 withusingso 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 returnedFundedis 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-lessOperationCanceledExceptionabove, 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.
PooledConnectionLifetimeis two minutes. -
A dropped connection is no longer reported as a cancellation.
OperationCanceledExceptiondoes not mean the caller gave up:RequestManager.RejectAllWithCancellationbuilds one with no token behind it and rejects every pending request with it, andconnection.cscalls 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 endedFundWallet's task cancelled rather than faulted - past everycatch (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 askIsCallerCancellationnow, 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-preservingcatchabove 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 theInnerExceptionwhen nothing arrived. It does not pick the wording: an account the faucet never paid answersactNotFoundon 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
HttpClienttimeout is no longer reported as a cancellation. OnlyHttpRequestExceptionwas converted intoXRPLFaucetException, but the client reports its own 100-secondTimeoutas aTaskCanceledException- anOperationCanceledException, 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 everycatch (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 noContent-Typeheader threwInvalidOperationExceptionout ofGetValues- 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 withTryGetValues.- an unsuccessful HTTP status was written to
Consolefrom 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
- an unsuccessful HTTP status was written to
-
NormalizeInnerTransactionno longer rewrites the transaction it is given (#157). The method stripsTxnSignature,SignersandLastLedgerSequenceand overwritesFee,SigningPubKeyandFlags. It did that to the caller's ownJsonObjectand 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 aJsonObjectand did not when it was anything else - the same call, with aliasing decided by a type test the caller cannot see SignAsBatchPartdepended on the mutation, and not visibly. It normalises each inner transaction, hashes the results into the batch preimage, and finally encodesouterinto the blob - and the normalised fields reached that blob only because normalisation rewrote the objects living insideouter. 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 = ""andtfInnerBatchTxn, 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
- the two overloads no longer disagree.
-
A malformed
RawTransactionsentry is named instead of failing inside a converter (#160). An element that is not a JSON object was refused bySystem.Text.JsonasExpected StartObject tokenthrown fromDictionaryObjectConverter- 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 aValidationExceptionthat says what is wrong.GetBatchSignerAccounts, the gate every batch-signing path reaches before any XLS-56 check, now refuses it asRawTransactions[i] must be an object., and the same holds one level down forRawTransactions[i].RawTransaction.- an element is judged by what it serializes to, never by its runtime type. A
JsonArraybuilt throughAdd<T>holds aJsonValuerather than aJsonObjectand 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 aJsonArrayor aList<object> SignAsBatchPartno longer filters its inner-transaction loop onn is JsonObjecteither. 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 droppedBatch.Validatecalled such an element null in its message when it was, for instance, a string. It now says what is actually wrong
- an element is judged by what it serializes to, never by its runtime type. A
-
GetBatchSignerAccountsno 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 replacedRawTransactions[i].RawTransactionin the caller's own dictionary with a converted copy - theIEnumerablebranch aliases an element that is already aDictionary, so the assignment landed in the caller's object. It is the gate every batch-signing path reaches throughVerifyBatchSubmitter, 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
RawTransactionworks from aJsonNodebuilt 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
- the conversion is still made, for reading; only the store-back is gone. No consumer needed it: every reader of
-
The signing path builds its
JsonSerializerOptionsonce (#147).XrplBinaryCodec.ObjectToJsonNodeconstructed a fresh instance on every call, and every signing operation goes through it -Encode,EncodeForSigning,EncodeForSigningClaimandEncodeForMultiSigningall route there. Measured end to end onEncodeForSigning, 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.ToHexhad the same pattern on a colder pathXrpl.BinaryCodecmoves to 11.0.1.0 for it. The other base packages are untouched and stay where they are - they are consumed byProjectReference, so a package built at a newer version keeps depending on the published ones
-
An amount the ledger allows but
decimalcannot hold is refused, not guessed at (#148).Currency.ValueAsNumberanswered such values three different ways: a positive one clamped todecimal.MaxValue, a negative one threwFormatException, and a very small one quietly became zero. XRPL issued currency runs from1e-81to roughly1e96- a 16-digit mantissa with an exponent in[-96, 80], per rippled'sSTAmount- whiledecimalstops near7.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. Returning7.9e28for1e96is wrong by 67 orders of magnitude, and it did not stay contained:GetBalanceChangessubtracts two balances, so the clamped value went on to throwOverflowExceptionfrom arithmetic instead - the negative case was a parse bug. The fallback's
NumberStylesexpression came toAllowExponent | AllowDecimalPoint-AllowLeadingSignwas 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-28still returns zero, and the asymmetry is deliberate. A balance of1e-81rounded to zero is zero at any scale a caller can act on; failing over it would cost more than it protects. An amount of1e96reported as7.9e28is not in that category - the threshold is nowhere near the protocol's ceiling:
1e29is barely abovedecimal.MaxValueand was already unreachable. A token with a large supply meets this without going anywhere near the ledger's limits Offer.AmountEachreads 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 andGetBalanceChangesnow say so in their own documentation rather than leaving it to be discoveredCurrency.ToString()falls back to the raw value rather than letting the getter throw through it. By conventionToStringdoes 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.
G16keeps the ledger's sixteen significant digits and rounds to nearest - which is what rippled does, so it stays - but at the top ofdecimal'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.000000000000000001do 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 - turns1e-18into 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
Numberdefaults toToNearest, 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 -AmmMathreturns 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
- the clamp is gone. An amount above the range now throws
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.PathStep — List<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 wayA 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
AMMDepositwould credit before submitting it, so consumers reached for the widely quotedT·(√(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.AmmMathis static and needs no client:LPTokensForSingleAssetDeposit,LPTokensForSingleAssetWithdraw,LPTokensForProportionalDeposit,AssetsForProportionalDeposit,AssetsForProportionalWithdraw, plusTradingFeeFractionandDiscountedTradingFeewith theTradingFeeScale(100 000) andAuctionSlotFeeDiscount(10) constants behind them- the single-asset pair are equations 3 and 7 from rippled's
AMMHelpers.cpp-lpTokensOutandlpTokensIn- transcribed rather than derived. The two are not symmetric, and the asymmetry is easy to get backwards:lpTokensInmultiplies by the fee wherelpTokensOutmultiplies by1 − 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, andAMMCreatehands 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 fromamm_info's auction slot when the account holds it - the swap, and the inverse of each equation.
SwapAssetIn/SwapAssetOutare rippled's own, equation (2) inAMMHelpers.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.SingleAssetDepositForLPTokensandSingleAssetWithdrawForLPTokensare equations 4 and 8 - what anAMMDepositcarryingLPTokenOutwill cost, and what anAMMWithdrawcarryingLPTokenInreturns. 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_inforeports 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.
TradingFeeis in units of 1/100 000 and rippled caps it at 1000 (kTradingFeeThreshold, nowAmmMath.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_3rippled rounds the final multiplication against the caller both ways -lpTokensOutdownward,lpTokensInupward - 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 ofSTAmount's 15 significant digits decimalthroughout, and a square root written for it.Math.Sqrtcarries 15 significant digits againstdecimal'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_infoandnft_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 itnft_sequence. A test pins the name that arrives on the wire - history entries are the same shape
account_txreturns, soTransactionSummaryreads 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 theIinterfaces, as with any transaction from a ledger - both are Clio-only. A plain rippled node answers
unknownCmd, which arrives as an ordinaryRippledExceptioncarrying 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
- an owner cannot be read out of
-
A failed submission arrives as something a caller can act on:
TransactionFailedException(#131).SubmitAndWaitthrew a bareRippleExceptionwhose only content was a sentence, so tellingtecINSUFFICIENT_PAYMENTfromtecEXPIREDmeant reading the text - and the classes of code mean entirely different things:temis a malformed request to fix,tecwas applied with the fee taken,termay work later. The hash was not available at all, and the hash is exactly what is wanted after atec: 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) andReachedLedger - nothing breaks. It derives from
RippleExceptionand 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 ReachedLedgeris read from the result code, not from whetherResulthappens 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. Atecwas applied either way, and which moment won a race is not something a caller should have to reason about.Resultcan therefore be null whileReachedLedgeris true;Hashis present in both
- the new type carries
-
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 onITransactionRequest. A variable typed as the interface was therefore written as the interface: every field of the actual transaction type gone - a payment with noAmountand noDestination- 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 ownJsonSerializerOptionsgot 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":16instead of"Payment", because that converter is on the class property too, andSigningPublicKey/TransactionSignatureon the wire instead ofSigningPubKey/TxnSignature, because the[JsonPropertyName]attributes are on the class as well - declared on
ITransactionRequestandITransactionResponsein addition to the classes. Nothing else changes: the converter already dispatches onvalue.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
- it worked as long as everything went through
-
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.IsMPTTokenanswered 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 upNFTokenAcceptOfferno longer declaresNFTokenID(#129). rippled'stransactions.macrogives that transaction exactly three of its own fields -NFTokenBuyOffer,NFTokenSellOffer,NFTokenBrokerFee- and the SDK's ownTxFormatalready 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 asModels.PathaboveValidateNFTokenAcceptOffernow refuses the same offer on both sides (#134). rippled compares each offer's owner against the submitter separately - the two blocks in itspreclaimread 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.Validateruns only when a consumer calls it- Reading history is documented (#135). What comes back from
account_tx,txorSubmitAndWaitis the response half of a type pair, sosummary.Transaction is NFTokenCreateOffercompiles, 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 anIinterface; match on that. Written onTransactionSummary.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 - theConfidentialMPTtransactions 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
Memosarray at 1024 bytes inpassesLocalChecks→isMemoOkay. That is a local check: the transaction is not relayed, reaches no ledger and costs no fee - the consumer simply finds out, after building, autofilling and signing it, that the node will not take it, and the answer names no field. The SDK checked the array's shape and never its size.MemoRules.Validateis called from every public entry that signs -Sign,SignAsBatchPart,SignAsSponsor,SignAsLoanCounterparty- and deliberately not fromValidation.Validate, which production code calls nowhere and which would have made this a rule nobody runs. GuardingSignalone 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
MemoDatafit 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 MemoTypeandMemoFormatmust also decode to characters RFC 3986 allows in a URL; the exception names the offending byte.MemoDatais 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/MemoFormatinside aMemo, 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 theserver_infothatSetNetworkIdsends straight after it. The socket really does open for a moment before a failingOnConnectedhandler 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 throughDisconnect(), which rejects everything in flight withOperationCanceledException— 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
NotConnectedExceptionbefore 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_infotoo, soConnect()threw while the client went on to connect. Measured: the caller gotOperationCanceledExceptionandIsConnected()wastruethree 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
NotConnectedExceptionwhen 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_infoback 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
- the give-up path now rejects in-flight requests with
-
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.ChangeServerannounced 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 aConnectingstatus — the same one a first connection sends. The client went on reportingConnected, 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
RestoringConnectionstatus, 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 wasOnDisconnect. The receive loop had one way out that reported nothing: its ownwhilecondition. 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, soOnDisconnectfires 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
OnDisconnectis unchanged and still means what it meant — a socket closed.OnSessionEndedis the one thing to subscribe to in order to know a resubscribe is due, and carries aSessionEndReason(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(Xrpl10.12.0.0), before all of the stream work in this release — sameConnected, same missing restoration, 33 seconds of silence. The demo did not change between the two, and theconnection.csdiff 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.Pathis 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.cswas the one test file importing bothSystem.IOandXrpl.Models.Methods, and it had to writeSystem.IO.Path.Combinein three places while its neighbours wrotePath.Combine. Those three qualifications are gone in this change, which is the check that the collision is gone with them. Consumers paid more: withImplicitUsingson, a singleusing Xrpl.Models.Methods;was enough to turn anyPath.Combinein the file intoCS0104 - 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 itsPath, where the name is right - everything around it already said step:
PathStepType,Validation.IsPathStep,TestUPathStep, and xrpl.js, where this isPathStepandPath = 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]onaccount,currency,issuer,mpt_issuance_idandtypeare 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 andList<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.csandTestUPathStep.cs- so they no longer drag in the namespace that caused the collision in the first place. Bothusingdirectives are deleted rather than left as decoration - migration: replace
PathwithPathStepand addusing Xrpl.Models.Common;, or putusing Path = Xrpl.Models.Common.PathStep;at the top of the file for now
- it collided with
-
Xrpl.Models.Utils.Indexis nowModelUtils(#117, the same defect one layer over).Indexwas a calque of the barrel fileutils/index.tsit was ported from, and it collides withSystem.Index, which is in scope in every file whether anyone asked for it or not.Payment.cscarriedusing 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 matchesModelUtils.cs, the file it always lived in -
Xrplgoes 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.BinaryCodecis already at 11.0.0.0;Xrpl.AddressCodecandXrpl.Keypairsstay 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.Infogains ten, not the seven the report listed. Measuring against a node rather than working from the list found three more -git,node_sizeandvalidator_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_usis a string inserver_infowhile the same field is a number inserver_state;initial_sync_duration_us,jq_trans_overflow,peer_disconnects,peer_disconnects_resourcesandtimeare all strings.portsis a list of{port, protocol[]}, andgitandvalidator_listare objects, so three small types come with them AccountLines.Validated- the one sibling result model that never declared it, though rippled writes it throughlookupLedgerunconditionally.AccountInfo,AccountObjects,AccountNFTs,AccountCurrenciesandNoRippleCheckhave always had itLOLedger.ClosedLedgerandLOLedger.OpenLedger- aledgercall naming no ledger answers with two whole structures rather than one. Not to be confused withBaseLedgerEntity.Closed, which is the boolean inside a ledger: the same word for two different things is why these went unnoticedLOEscrow.Flags, replacing a//todothat 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 nolsfEscrow*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.FromJsonrefused 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::parseObjectrecurses and answersunknownFieldat every level, so such a transaction does not parse at all - the Batch case was worse than reported.
BatchNormalizer.ComputeInnerTxIdparsed 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.BinaryCodecgoes 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 withXrplrather than trailing behind.Xrpl.AddressCodecandXrpl.Keypairsare untouched since the last release and stay where they are - which is correct, not an oversight: they are consumed byProjectReference, so a newerXrplkeeps 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 theXrplpackage and the two assemblies share noInternalsVisibleTo - 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.
FilterIsSigningstill applies to the top level alone, because filtering nested objects would change what gets signed Encodestays lenient about unknown fields, deliberately - members of anSTObject, 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- andHashSignedTxhashes 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'sdefinitions.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, soEncodealready 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,XChainBridgeand 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
StObjectwere a second hole, found in review.Issuein its MPT form,XChainBridgeTypeand the steps of aPathSetparse 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 typeandtype_hexare named as members a path step may carry, because refusing them would break the ordinary flow:ripple_path_findanswers with atypeon every step, this SDK declares it onPathand 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
- 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
-
failHardremoved fromGetSignedTx. 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.Submiteven passed an explicitfailHard: falseinto it while keeping its own value for the actual submit - the author already knew it meant nothing. Callers ofSubmitandSubmitAndWaitare unaffected; a direct caller ofGetSignedTxdrops the argument. Positional callers get a compile error rather than a silent rebind, since the next parameter is anXrplWallet -
The stream queue now exists before consumer code can subscribe (#113).
StartMessageProcessorran at the very end ofOnceOpen, afterResolveAllAwaiting()and after theOnConnectedcallback. 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: outsideStreamMessageQueueCapacity, uncounted byDroppedStreamMessages, 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.
StartPingTimerbegins withStopPingTimerSync, which also calledStopMessageProcessor, so simply starting the processor earlier had it torn down again moments later - measurably:TestUDroppedStreamMessagesCountsWhatTheConsumerNeverSawwent 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,ChangeServerandRetireCurrentSessionAndReconnectAsync- 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
OnMessageon 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 blocker was an unrelated coupling, and that is the real fix.
-
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-entryaccount_txgained 156 fabricated members and dropped 28. After this level the same capture gains 4 and drops 15, and every fabricated member left is theAmount/DeliverMaxrename, which is the next level.- Scope came from the protocol, not from a hand count. The repository already vendors rippled's
ledger_entries.macroand parses which fields are Required, Optional or Default. A new conformance test,TestUNullabilityConformance, reads it: a field the protocol allows to be absent must map to a property that can express absence. It found 9 direct violations —AMM.TradingFee,FeeSettings.ReferenceFeeUnits/ReserveBase/ReserveIncrement,LedgerHashes.FirstLedgerSequence/LastLedgerSequence,MPTokenIssuance.AssetScale,PayChannel.SourceTag/DestinationTag - A second, broader rule applies to the same models. rippled's requirement flag describes the ledger object, but these models double as the contents of
PreviousFields,FinalFieldsandNewFields— andPreviousFieldscarries 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 beingLedgerEntryTypeon the shared base, which one edit covers for all 31 models. On top of that, 21 properties on transaction models andAccountInfo.LedgerIndex/LedgerCurrentIndex LedgerEntryTypebecame nullable too, and the constructors that stamped it were removed along with the unconditional stamp inLedgerObjectConverter. Without both, the property stayed non-null by construction and the change would have been decorative — round-trip on aModifiedNodewent from 6 fabricated members to 2 to 0LONFTokenPage.PreviousTxnLgrSeqwaslongwhiledefinitions.jsondeclares the fieldUInt32and every other model usesuint. Corrected touint?— unrelated to nullability, found while surveying[JsonExtensionData]onBaseLedgerEntryandBaseTransactionResponse. A member no model knows — a field arriving with a new amendment — used to vanish silently. It now lands inUnknownFieldsand survives a round trip. Verified that the hand-written converters (LOConverter,ModifiedNodeConverter,TransactionResponseConverterand the rest) do not swallow it: they parse only the envelope and delegate the fields to the reflection path, which honours the attributeBaseResponse.IdandErrorResponse.Requestleftobject— the known remainder of the first level. Both were filled with aJsonElementwhose pooled array is never returned; measured at 3 672 B retained per envelope carrying anidagainst 217 B without one, on every response. Both now record bounds, exposed asRawIdandRawRequest, and the request id is parsed straight from the bytes withUtf8Parserinstead 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
?? 0only 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 < SignerQuorumreturns false when the quorum is absent, so the "insufficient signatures" check silently stopped firing - a lifted
(Flags & lsfAccepted) != 0returns true whenFlagsis 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
?? 0by reflex. - Scope came from the protocol, not from a hand count. The repository already vendors rippled's
-
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
LOAccountRootwent withoutWalletLocator/WalletSizeuntil a manual pass, and howsfLEVersionhad 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 atapi_version: 2and one at v1, which is what catches the case where the node sends bothAmountandDeliverMaxfor the same value — including one withbinary: true, one carryingwarning: "load", and a ten-entryaccount_txwith the metadata that started all of this. AREADME.mdbeside them records where and when they came from, because updating them has to be a deliberate actTestUResponseFidelitydeserializes 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 outsideresultin the WebSocket envelope and reaches callers throughXrplResponse<T>.Status; and$.warninginsideaccount_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_txcapture used throughout this section: 156 fabricated members before any of this work, 0 after it — the last 4 (theAmount/DeliverMaxrename, called out as "the next level" above) are gone along with the rest.-
PaymentResponseno longer substitutesAmountforDeliverMaxon 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.PaymentResponsenow 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:Amountalone comes back asAmount,DeliverMaxalone asDeliverMax, both as both, and an object built in code asAmount.Amountitself 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 writesAmount, 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, theDeliverMaxalias), not just here. Checked directly againstBase/Xrpl.BinaryCodec/Enums/definitions.json:Amount,DeliverMinandSendMaxall have an entry there;DeliverMaxdoes not, because it is a JSON API v2 presentation-layer rename with no binary field code of its own.Payment.ToJson()feedsXrplWallet.Sign()→EncodeForSigning, which looks fields up by name indefinitions.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.PaymentResponseis 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 toTxV1()(breaking) — it pinnedrequest.ApiVersion = 1regardless ofClientOptions.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:TransactionResponsehas no field for API v2'stx_json, so handing it a v2 payload would lose the transaction wholesale, not just a field name, the wayPaymentResponseabove 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 thetx_json/metashape. No[Obsolete]shim, consistent with this repository's major-version policy. Six integration test files and one hand-writtenIXrplClienttest double were the only callers; nothing inside the SDK itself calledTx() -
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_isoandctidnow live onIBaseTransactionResponse/BaseTransactionResponse, reached by every transaction response type.ctidalso exists separately onTransactionSummary(Models/Methods/AccountTransactions.cs), and both are real: the singulartxmethod reportsctidas a sibling oftx_json, which is whatTransactionSummary.Ctidreads, whileaccount_txnestsctidinsidetx_jsonitself, which lands on the transaction's ownCtidinstead — two different positions in the protocol, not one field modeled twice by mistake.statuswas closed by givingXrplResponse<T>its ownStatusmember: it sits besideresultin the envelope, not inside it, so it was never reachable throughRaw.meta_blob/tx_blobcover API v2 withbinary: true, where rippled sends the transaction and its metadata as top-level hex siblings instead of the usualtx_json/meta— before these existed, that response shape lost its body wholesale (measured: 2246 B in, 195 B out) -
FromStringDateTimeConvertersilently returnednullfor 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 sendsclose_time_isowith a literalZsuffix ("2013-03-12T23:16:50Z"), whichzzzrejects.TryParseExactreturnedfalseon every real capture, soclose_time_isocame backnullregardless of whether a property was there to receive it. Fixed by switching to the"K"custom specifier, which accepts both forms, plusDateTimeStyles.AdjustToUniversalso a numeric-offset value still normalizes to UTC instead of being left in local time -
TestUBaseTransactionResponseFieldsandTestUPaymentDeliverMaxRoundTrippin all of the above against real captures — a livetxresponse, a liveaccount_txentry reshaped to the API v1 wire form the way rippled genuinely flattens it, and a livebinary: truecapture — 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 atapi_version = 2:close_time_iso,ctid,tx_json.DeliverMaxandmeta_blobare dropped;PreviousFields.Flags = 0,LedgerEntryTypeand — on a Payment —TransactionType = "AccountSet"are invented, 156 fabricated members on a ten-entryaccount_txalone. For a wallet rendering a transaction so a person can check what they are signing, that is false precision.XrplResponse<T>carriesResult(the projection),Raw(theresultmember exactly as sent), and the envelope the client used to unwrap and discard:ApiVersion,Warning,Warnings,Forwarded.Warningsis 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 usingvar, which break either way — leaving a partial compatibility harder to migrate than a clean break, and hiding thatRawexists at all RawJsongainedDeserialize<T>(),ToJsonElement()andHasTopLevelProperty(), so a consumer does not reach forJsonSerializerwith options of their own — the XRPL models depend on the converters inXrplJsonOptions.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 ofRawResult. The method isinternalon 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 throughRaweither: that is theresultmember, whilewarninglives 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
txmethod pinsapi_version = 1regardless ofClientOptions.ApiVersion, so itsRawis the honest text of a v1 response. A caller on API v2 — a wallet checking what it is about to sign — wantsTxV2(...), which mapstx_jsonandmetaas siblings the way v2 sends them. This is resolved further down in this same release: the method is renamedTxV1()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>), andGRequest<T, R>itself.Connection.RequestandConnection.GRequest<T, R>change with them, andRequestManager'sXrplRequest.Promise/XrplGRequest.Promisenow resolve toResolvedResponserather than the value directly.ResolvedResponseand theXrplResponse.From<T>unpacker are public for exactly that reason:RequestManageris 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 aResolvedResponsedirectly, for a caller that already has one off an awaitedPromise— the mismatch theobjectoverload can only catch at run time, as anXrplException, 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(...)andGRequest<T, R>(...)are no longerasyncmethods — they delegate straight toConnection's versions of themselves. An argument-validation exception they raise now leaves the call synchronously, before aTaskis even returned, rather than surfacing when the returned task is awaitedXrplResponse<T>gainedDeconstruct(out T, out RawJson), sovar (result, raw) = await client.AccountInfo(request)works — the one-line fix for the call sites that broke hardest on this change, the ones usingvar— andHasNextPage, reading the samemarkersignal as theBaseResponse.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 holdsTestUXrplResponseproves 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 inRaw. That assertion originally had a second half — the same member proved absent from the re-serializedResult— which extension-data capture later made false; it now proves the opposite, that the member reaches the caller on both sides, whileRawremains 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-typedT(Unsafe.SizeOf; 56 bytes for a value-typedT, and none of the 43 methods are parameterized with one, so 64 is the figure that applies in practice — it grew by 8 whenWarningandStatuswere 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 theJsonElementpath, 5.57x typed, 2.18x over the socket
-
The
resultmember was parsed twice and its intermediate document was never given back (breaking) —BaseResponse.Resultwas typedobject, which System.Text.Json fills with a self-containedJsonElement. Building it costs aJsonDocument.ParseValue, which rents its backing array fromArrayPooland 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 whereresultsits 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) andJsonSliceConverter, which reaches the bounds throughUtf8JsonReader.TokenStartIndex/Skip()/BytesConsumedwithout materializing the subtree.WritethrowsNotSupportedException: 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-sizednew 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.HandleResponsetakes the frame and pairs it with the bounds. Thestringoverload now encodes to UTF-8 first, and that is required, not incidental: onDeserialize(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.Resultis gone, replaced byRawResult— the bytes as sent. The bounds themselves are an implementation detail and stay internal.RequestManager.HandleResponse(ReadOnlySpan<byte>)is gone, replaced byHandleResponse(byte[])— a span cannot be stored, and the frame must now outlive the call. As a consequenceHandleResponse(null)no longer compiles: it is ambiguous between thestringandbyte[]overloads. No[Obsolete]grace period, consistent withPath.TypeHexin 10.11.0.0 -
ownership moved with the signature. The returned response keeps the array and cuts
RawResultfrom 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.TestUResponseAliasesTheFrameItWasGivenpins this -
the frame reference is deliberately
internal. The bounds are only meaningful for a reader that covered one contiguous buffer, which theStreamoverloads 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, soRawResultcomes 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 -
RawResultis empty on any envelope you deserialize yourself. It is populated only byRequestManageron the request path. Stream messages,LedgerStreamResponseand friends, and anything you hand toJsonSerializer.Deserialize<BaseResponse>come back with no frame and therefore an emptyRawResult— by design, see theinternal Framenote above. None of those paths readresultbefore, either -
behaviour shift on a hand-assembled response.
RequestManager.Resolveused to fall back toJsonSerializer.Deserialize(result.ToString(), type)for aBaseResponsethat was built rather than parsed off the wire. Such a response now has no frame, soDeserializeResultsubstitutes{}and the promise completes successfully with a defaulted object instead of carrying the assembled values. Unreachable through the client — the only caller ofResolvealways has a frame — butResolveis 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
ConnectionbindsOnBinaryMessage: 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 comparedResultagainstDictionary<string, object>, which that member never was; it held aJsonElement. The method has no callers inside the SDK, which is how it survived. It now scans the raw result withUtf8JsonReaderfor a top-levelmarker, skipping over each non-matching member's value so a nestedmarkercannot 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.jshasNextPage.ts. Worse, its fully qualified name carried noTestU, so it would not have run under the CI filter even with tests in it. It is nowTestUHasNextPagewith ten tests, including an escapedmarkerkey, near-miss keys, non-object results, and amarkernested inside an array of objects
- its test file existed as an empty stub — a class with no
-
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).TestTypedResponseParsingStaysWithinItsAllocationBudgetis the one that sees the change.TestUEnvelopeRetainsNoMoreThanTheFrameguards retention -
BaseResponse.IdandErrorResponse.Requestwere left asobjectat this point — each still building aJsonElementwith an unreturned rental, measured at 3 672 B retained per envelope carrying anidagainst 217 B without one, on every response, andSugar.SubmitAndWaitcatchestxnNotFoundin a polling loop whereRequestpaid it repeatedly. Both are fixed further down in this same release: they becameRawIdandRawRequest, and the retention budget tightened from 8 192 to 6 144 bytes -
LONFTokenPage.PreviousTxnLgrSeqchanged fromlongtouint?(breaking) — the field isUInt32perBase/Xrpl.BinaryCodec/Enums/definitions.json, matching every other ledger entry'sPreviousTxnLgrSeq; the priorlongwas both wider than the protocol and inconsistent with its siblings -
ServerState'sStateLedger.ReserveBase/ReserveIncchanged fromuinttouint?(breaking) — the same class of defect as the rest of this section, but found on aMethodsmodel rather than a ledger entry or transaction, which is exactly why it slipped pastTestUNullabilityConformance: that test is built offledger_entries.macroand the transaction formats, andServerStateis neither. Aserver_stateresponse missing either field used to read back as0, soBalances.GetXrpFreeBalancecomputed the account/owner reserve as zero and returned a free balance inflated by the reserve it failed to subtract.GetXrpFreeBalancenow throwsValidationExceptionwhen either field is absent, the same shape as the existingOwnerCountcheck 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 marksvalidatedoptional on fields rippled always sends unconditionally vialookupLedger, 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, sinceClosedis declared on bothBaseLedgerEntityand theIBaseLedgerEntityinterface beside it:LOBaseLedger.LedgerIndex— unconditional for the dedicatedledger_closedcommand, but the same property is inherited byLOLedgerfor the generalledgercommand, where rippled's sharedlookupLedgersetsledger_indexonly when the resolved ledger is closed; an open/current-ledger request getsledger_current_indexinstead and omits this member entirelyHashOrTransaction.LedgerTransaction.Validated— structurally absent on aledgerresponse's expanded transactions when the request used API v1; rippled'sLedgerToJson.cpponly writes it inside theapiVersion > 1branchBaseLedgerEntity.Closed(andIBaseLedgerEntity.Closed) — omitted from the nestedledgerobject for a non-binaryledgerresponse when the ledger is open andfull: truewas requestedNoRippleCheck.LedgerCurrentIndex— samelookupLedgergate asLOBaseLedger.LedgerIndexabove: absent whenevernoripple_checkresolves against a closed/validated ledgerServerFeatures.LedgerIndex— worse than merely conditional: rippled'sfeaturehandler never callslookupLedgerand never writesledger_indexat all, so this member was fabricated as0on every response, not just some. The customServerFeaturesConverterhad its own: 0default baked into the read path, a second copy of the same defect the attribute-based fix elsewhere in this codebase does not reachLedgerStreamResponse.LedgerIndex/ReserveBase/ReserveInc/LedgerTime/FeeBase— this class models thesubscribecommand's own synchronous reply when the client subscribes to theledgerstream (NetworkOpsImp::subLedger), a different rippled code path from the asyncledgerClosedpush thatLedgerStreammodels;subLedgergates the whole block onledgerMaster_.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 themLedgerStreamResponse.TxnCount— not conditional but absent outright:subLedgeremits notxn_counton this path at all, which this type's own summary already stated ("does NOT include the 'type' nor 'txn_count' fields") while the property fabricatedtxn_count: 0into every reply anywayLedgerStream.FeeRefandLedgerStreamResponse.FeeRef— the everyday case, not an edge case. rippled guardsfee_refwithif (!rules().enabled(featureXRPFees))on both paths.XRPFeeshas been active on mainnet since 2023, so no current node sends the member at all: everyledgerClosedevent a wallet round-tripped gained afee_ref: 0the node never wrote. On thesubLedgerpath it is conditional twice over — inside the validated-ledger gate and behind the amendment checkServerFeatures.Validated— the samefeature-handler fact recorded one line above forLedgerIndex: the handler writes novalidatedeither, andServerFeaturesConvertercarried the matching&& v.GetBoolean()in its read path, which collapses "absent" and "false" intofalse. ItsWritethrows, so nothing was fabricated on output here — this one is about reading the node honestly, not about round-tripValidationStream.LedgerIndex— rippled'spubValidationsets it only when the underlyingSTValidationcarries the optionalsfLedgerSequencefieldTestUNodeMayOmitProtocolFieldsproves it by round-tripping the exact node-omission shape for each one: deserialize, assert the property isnull(not a fabricated0/false), re-serialize, assert the JSON key is absent. Proven to fail, not merely pass, twice over: revertingHashOrTransaction.LedgerTransaction.Validatedalone turned 2 of its 11 tests red, and revertingFeeRefturned 4 of 13 red- Everything else the same audit checked stays as it is, each for a rippled-sourced reason:
AccountOffers' per-offerseq,account_tx's top-levelvalidated/limitand each transaction'svalidated,account_info/account_currencies/account_nfts/account_objects'svalidated(alllookupLedger-unconditional),AccountInfo.AccountQueueTransaction.AuthChange,Fee.LedgerCurrentIndex/Drops.BaseFee,PathFindResponse/PathFindStream'sfull_reply,Subscribe.LedgerStream'sledger_index/reserve_base/reserve_inc(the async push, unconditional — contrast withLedgerStreamResponseabove),BookChangesStream.LedgerIndex,ManifestStream.Seq,OrderBookStream/TransactionStream'svalidated, andTransactions.BookOffers.Offer.PreviousTxnLgrSeq(SoeRequiredon every realltOFFER). Theseqsub-fields onLedgerEntry'sPermissionedDomainQuery/OfferQuery/EscrowQueryare request parameters rippled rejects asmalformedRequestwhen 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
RawResultbecauseRequestManager.HandleResponse(byte[])keeps the frame and pairs it with the envelope throughAttachFrame. A stream message —transaction,ledgerClosed, and the rest — never went through that:Connection.EnqueueStreamMessagequeued astring, 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 exceptLedgerStreamandErrorResponsebefore this change — gainedRawand an internalAttachFrame(byte[]), mirroringBaseResponse. Unlike a query response, a stream message carries noresultenvelope to slice a member out of: the frame passed toAttachFrameis the event, soRawspans the whole of it.JsonSlicegainedOfDocument(byte[])to compute those bounds - the sameTokenStartIndex/Skip()/BytesConsumedtechniqueJsonSliceConverteruses for a member, run once for the top-level value insteadLedgerStreamnow extendsBaseStreamrather than declaring its ownTypefield, which is what lets it carryRawthe 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 setIncludeFields- 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, sinceLedgerStreamis only ever produced from aledgerClosedmessage, 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 usingLedgerStream.Typeneeds to change, but not binary-compatible: an assembly compiled against the previous package fails at runtime withMissingFieldExceptionuntil it is rebuilt.TransactionStreamgainedRawTransaction— the transaction alone, not the whole eventRawcarries, which is the one thing a wallet displaying a transaction for signing actually asks for.Transactionand its private API v1 alias already claimtx_jsonandtransactionas 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,RawTransactioncannot be filled through a converter-backed property.JsonSlicegainedFindTopLevelMember(byte[], ReadOnlySpan<byte>)for exactly this case: it scans the frame directly, the same wayRawJson.HasTopLevelPropertychecks for presence, andTransactionStream.AttachFrameuses it to trytx_jsonfirst and fall back totransactionConnection's stream pipeline now carries the frame, not text (not breaking: every member listed here isprivate, and no public signature changes type)._streamMessageChannelisChannel<byte[]>, notChannel<string>;ProcessStreamMessageAsync,ProcessStreamMessageFireAndForgetAsync,EnqueueStreamMessageandNotifyStreamProcessingErrorAsyncall take the frame.OnMessage(string)— still public, still how every existing test feeds a message in by hand — builds a frame withEncoding.UTF8.GetBytesexactly the wayRequestManager.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: abyte[]frame is roughly half the size of the UTF-16stringit 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 -TestUTransactionStreamAttachFrameRetainsNoMoreThanTheFramemeasured 0 B marginal per instance forAttachFrameover 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 throughOnErrorasbadMessagerather than raised, and carrying bytes instead of text must not turn that into a throw fromEncoding.UTF8.GetBytes(null)at the frame-building step itself.TestNullMessageIsStillReportedThroughOnErrorpins it
- the channel's bound is unchanged (10 000,
TestUStreamRawJsoncovers the pipeline end to end -OnMessagethrough the channel toAttachFrame- rather than only the model in isolation: aledgerClosedmessage with a field (network_id) no property models survives toRawbyte for byte and — sinceBaseStreamgained[JsonExtensionData]— reaches a re-serialization of the typedLedgerStreamas well, which is what that assertion was flipped to prove, andRawTransactionis checked against both the API v1 and v2 envelope, independently, throughJsonDocument.GetRawText()rather than by re-deriving the same offsets the code under test computes
-
JsonSlice.FindTopLevelMemberreturned the first occurrence of a duplicate top-level member;JsonSerializerreturns the last — rippled does not send a frame with two top-leveltx_jsonmembers, but nothing between the socket and this code rules one out (an intermediate proxy, a compromised link). Such a frame leftTransactionStream.RawTransactionpointing at the first occurrence while the deserializer-fedTransactionreflected the last, matchingJsonSerializer's own last-value-wins behaviour for a POCO property fed by a duplicate JSON member (the default unlessJsonSerializerOptions.AllowDuplicateProperties = false, whichXrplJsonOptions.Defaultdoes not set) — a wallet would show a person one transaction and sign a different one. The scan now continues toEndObjectand keeps the last match instead of returning on the first, matching the deserializer it feedsRawTransactionalongside -
FindTopLevelMember/RawJson.HasTopLevelPropertymatched case-sensitively whileXrplJsonOptions.DefaultsetsPropertyNameCaseInsensitive = true— a frame spelling the member"TX_JSON"populated the typedTransactionStream.Transactionthrough the case-insensitive deserializer whileRawTransactioncame back empty, sinceUtf8JsonReader.ValueTextEqualshas no case-insensitive form. Both now decode the property name throughUtf8JsonReader.GetString()(which also unescapes it, same as before) and compare withStringComparison.OrdinalIgnoreCase, matching the deserializer's own rule.RawJson.HasTopLevelPropertystill returns on the first match rather than scanning to the end — presence does not depend on which occurrence is meant, unlikeFindTopLevelMember's value lookup above -
BaseStream.TypeisResponseStreamType?now (breaking) — the same class of defect asLedgerEntryTypeearlier in this release, found on review of the stream work directly above.LedgerStream()'s constructor stampedType = ResponseStreamType.ledgerClosedunconditionally, 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 ofResponseStreamType.UNKNOWN(0) meantJsonSerializer.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 populatesTypecorrectly, the same as every other property on these classes — and absence now round-trips as absence (XrplJsonOptions.Defaultomits a null member on write). Does not touch stream dispatch:Connection.ProcessStreamMessageAsyncdecides which typed class to build fromBaseResponse.Type— an unrelatedstringproperty, deserialized separately from the raw JSON"type"member before the typedLedgerStream/TransactionStream/etc. instance exists — not fromBaseStream.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::pubLedgerandsubLedger, both paths),ctid(transJson, on every validated transaction) and theaccount_history_tx_index/_boundary/_tx_firsttrio (account_historysubscriptions) had no property on any stream model and no capture to fall into.BaseStreamnow carries[JsonExtensionData], mirroringBaseLedgerEntry/BaseTransactionResponse/BaseMethodResultfor their own families, so every stream type picks it up.LedgerStreamResponsedeclares its own, since it descends fromBaseResponse, whoseid/resultmembers are byte-range slices rather than parsed values. Note that nothing routes through that type today —SubscribereturnsXrplResponse<object>— so the capture there is correctness for whoever wires it up, not a live fix ctidgets a real property onTransactionStreaminstead of a dictionary entry — a wallet asking which transaction is this needs it typed, the wayHashisTestLedgerClosedRawSurvivesTheStreamPipelineByteForBytehad usednetwork_idas 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
BaseMethodResultto 47, includingTransactionSummary— what bothtxand everyaccount_txentry deserialize into, and the shape whosestatusloss this file previously documented as a known remainder. Excluded, each for a reason: request-side shapes (theledger_entry*Queryselectors,Book/BookCurrency,SourceCurrency,TakerAmount,AuthorizedCredential), the two types whose custom converters own the read path (ServerFeatures,GatewayBalancesResponse), and the stream types already covered throughBaseStream. - 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(reachesPayment.PathsandPathFindCreateRequest.Paths),AuthAccount(AMMBid), andAuthorizeCredentialEntry/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 — andStObject.FromJsonpassessigningOnlyonly to the top level, so a nested unknown member reaches the displayedtx_jsonbut 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 KnownLostMembersinTestUResponseFidelityis 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]fromBaseMethodResultturns it red
- stream events dropped fields rippled writes unconditionally.
-
Four ways
RawTransactionand the typedTransactioncould disagree — a wallet showing one transaction and signing another. Each is the defect this release exists to remove, arriving from the opposite side:JsonSlice.FindTopLevelMemberreturned the first occurrence of a duplicated key and stopped scanning; System.Text.Json takes the last. It now scans toEndObjectand returns the last, matching the deserializer- matching is now case-insensitive, because
XrplJsonOptions.DefaultsetsPropertyNameCaseInsensitive = true— a frame carryingTX_JSONfilled the typedTransactionwhile leavingRawTransactionempty TransactionStream.AttachFramepreferredtx_jsonunconditionally, while the typed side takes whichever envelope appears later (its two setters both dovalue ?? _transactionand run in document order). Both views now resolve the same envelope- an envelope explicitly set to JSON
nullis skipped rather than winning that ordering rule.value ?? _transactiondiscards a null, so{"tx_json":{…},"tx_json":null}leaves the real object on the typed side — resolving the slice to the trailing null emptiedRawTransactionwhileTransactionstill 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: anaccount_linespage 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
TransactionStreamdeclaresaccount_history_tx_index/_boundary/_tx_firstas properties rather than leaving them to capture, alongsidectid: rippled sendsaccount_history_tx_indexon every event of such a subscription, and the two flags on some (_boundarymarks the last transaction of a ledger,_tx_firstthe 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
- that measurement is why
-
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.ValueTextEqualsnow 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 underPropertyNameCaseInsensitive -
Three
UnknownFieldsdeclarations were removed fromLOLedger,LedgerEntityandLedgerBinaryEntity: 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 stayednullforever while the data sat on the subclass.LedgerClosedhands callers anLOBaseLedger, which would have read empty -
ErrorResponseremains the one stream-path type without a whole-eventRaw: it descends fromBaseResponse, notBaseStream, and getsRawResult/RawId/RawRequestinstead. 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 eventRawandRawTransaction— 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 wasclient.connection.OnTransaction: a property of a concrete class, so code written againstIXrplClientcould neither subscribe nor be exercised against a substitute client. The feature existed without a contract to reach it through.- every event
Connectionraises is declared onIXrplClientand forwarded to it.client.connection.OnXkeeps working unchanged — this adds a surface, it does not move one - forwarded, not relayed, and the distinction is the whole design:
add/removego straight to the sameConnection, 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.TestUUnsubscribingThroughTheInterfaceRemovesTheHandlerpins 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
Connectionoutlives the client: it is assigned once, in the constructor, andChangeServerswaps the session inside it rather than the object, so subscriptions survive a server change IXrplClient.connectionlost 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
- every event
-
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.DroppedStreamMessagesandIXrplClient.DroppedStreamMessagescount the discards, across reconnects andChangeServeralike, since oneConnectionserves them all. Any increase means events arrived and never reached a handlerConnectionOptions.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'sitemDroppedcallback, which runs insideTryWriteon 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).
EnqueueStreamMessageused 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 -StartMessageProcessorsays "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,OnceCloseall compare against_activeSession.SessionId- and the message path was the one exception:ws.OnBinaryMessagecalledIOnMessageFastPath(m)with no session at all.- that mattered because retirement is not instant.
RetireOldSessionAsyncruns 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
ChangeServerbetween 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
StreamMessageQueueCapacityframes (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 onlyConnectInternalAsyncinstalls 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 guardOnceOpenand the other lifecycle callbacks already use, and the only wayIsRetiringis published at all - a
nullsession means the caller has none to name (OnMessage, which anyone may call), and nothing is rejected in that case - consequence worth naming:
OnMessageno longer dispatches inline when there is no processor. It never did on the queued path, so this makes the two agree - but code that calledOnMessageon an unconnected client and read handler state on the next line was relying on the difference - what goes round the queue is now countable.
FallbackDispatchedStreamMessagescounts 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 toconnection- the only implementation that means anything.DroppedStreamMessageswas 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
ProcessStreamMessageAsynccomes afterJsonSerializer.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.
TryWritewas called for its side effect and its result ignored, on the reasoning that aDropOldestchannel never refuses - which is true of a full queue (it evicts, counts throughitemDroppedand reports success) and false of a completed one.StopMessageProcessorInternalcompletes 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:StartPingTimertears the processor down andStartMessageProcessorbuilds it again on every connect. Such a frame now takes the fallback path, where it still faces the session check Connection.StaleSessionFramesDroppedcounts what was discarded, kept separate fromDroppedStreamMessagesbecause 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 onIXrplClient, next toDroppedStreamMessages- a counter nobody can read is not observability. New interface member, with a default body forwarding toconnection: an external implementation ofIXrplClientkeeps compiling and may override it
- that mattered because retirement is not instant.
-
Request(Dictionary<string, object>)never delivered the API version, so one client spoke two protocol versions (breaking) — it stamped the version undernameof(ApiVersion), literally"ApiVersion". A dictionary is serialized verbatim, and rippled knows onlyapi_version: it ignores unknown fields and answers on its default, API v1. Measured on mainnet, the three spellings are not equivalent —api_version: 2returns the v2 shape, while"ApiVersion": 2and no version field at all both return v1. Soclient.AccountInfo(…)went out as v2 whileclient.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.ApiVersioncarries[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
ApiVersionsays, 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"] = 1in the dictionary or setApiVersionon the client TestURequestApiVersionreads 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 explicitapi_versionis not overwritten, and that both request paths of one client carry the same version.WebSocketTestServerBasegained the client-frame reader thatPagedResponseServerhad kept private, rather than a third copy of it
- the key is now the wire name, and a version the caller put in the dictionary themselves is still respected. The junk
-
TransactionStreamre-parsed the transaction on every read of it, and lost the hash under API v1 (breaking) — the same defectTransactionSummarywas fixed for in 10.9.1.0, left standing on the stream side.Transactionwas an expression-bodied property over twoobjectmembers holdingJsonElements:JsonSerializer.Deserialize<TransactionResponse>((TransactionJson ?? Proposed).ToString(), …). Three things wrong with that one line, on the busiest path the client has — every transaction of atransactionssubscription:- the transaction was rendered back to a string and parsed a second time. It was already parsed:
TransactionJson/Proposedareobject, which System.Text.Json fills with a self-containedJsonElement. Same round trip as the one removed fromRequestManager.Resolvebelow - 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
TransactionTypeand thenHashpaid 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
Hashwas mapped to the top-level field alone, sotx.Hashwas alwaysnullon v1 — which is what left theBlazor-WebAssemblydemo printing no hash, since it requests"ApiVersion": 1. Verified against mainnet in both directions - a message carrying neither envelope threw
NullReferenceExceptionstraight out of the property
TransactionStreamnow followsTransactionSummary:Transactionis typedTransactionResponseand mapped totx_json, a private set-onlyTransactionV1alias catches the API v1transactionenvelope, andHashfalls 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_indexandledger_hashneeded 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 touchesTransactionpays about 2.25 KB more per message; one that touches it once or more pays 2.7–12.6 KB less - breaking: the public
objectpropertiesTransactionJsonandProposedare gone — they existed only as raw envelopes for the getter to re-parse, and there is nothing left to re-parse.Transactionkeeps its name and type and gains a setter. Consistent with the removal ofPath.TypeHexin 10.11.0.0, no[Obsolete]grace period TestUTransactionStreamEnvelopepins both envelopes, the hash under both versions, the message carrying neither, and that repeated reads allocate nothing and hand back the same instance
- the transaction was rendered back to a string and parsed a second time. It was already parsed:
-
Every response was parsed twice and copied to UTF-16 twice — the cost of reading a response, measured rather than reasoned about.
RequestManager.ResolvedidJsonSerializer.Deserialize(response.Result?.ToString() ?? "{}", taskInfo.Type, ...).BaseResponse.Resultis typedobject, which System.Text.Json fills with aJsonElementthat already owns a private copy of theresultbytes — so.ToString()rendered that element back into a UTF-16 string and the serializer parsed the string a second time. On aledger_datapage atlimit=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 theresult, 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:DeserializeResultworks off the parsed node:element.Deserialize(type, options)for a typed model, and the element itself when the request asked forJsonElementorobject, which is what a consumer that needs the raw ledger objects asks for (the typedLOLedgerData.Statedrops unknown fields). ABaseResponseassembled by hand rather than parsed off the wire keeps the old string path. Behaviour is otherwise unchanged, including a missing or JSON-nullresult, which still yields what deserializing"{}"yielded- the socket path carries the frame as it arrived.
ConnectionbindsOnBinaryMessageinstead ofOnMessageReceived,IsLikelyResponseandRequestManager.HandleResponsehaveReadOnlySpan<byte>overloads, and the UTF-16 string is materialized — once, lazily — only for what genuinely needs text: stream messages and theOnWarning/OnServerWarning/OnErrorcallbacks. Thestringoverloads stay forConnection.OnMessage(string)and for external callers - the warning callbacks no longer pay for listeners that are not there. rippled attaches
warning/warningsto responses under load and on a reporting-mode server, and the dispatch built the UTF-16 text for them before checking whetherOnWarning/OnServerWarningwere 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
OnErroris 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 anOutOfMemoryExceptionwhile building it falls back to a literal placeholder so the classification still goes out.Connection.OnMessage(null)also keeps its old route throughOnErrorinstead of throwingArgumentNullExceptionout of the entry point - measured end to end against a local WebSocket server, 600
ledger_datapages 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 loweredDOTNET_GCHeapHardLimitthe pre-fix path reproduced the production failure exactly —XrplException: Failed to deserialize response for request <id>: Exception of type 'System.OutOfMemoryException' was thrown, withJsonElement.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_dataor toJsonElement: the second parse was on the path of every command. The repo's ownBenchmarkLedgerDataCrawl, which goes throughRequest→Dictionary<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 theJsonElementfigure because building aDictionary<string, object>boxes every value, which this change does not address TestUResponseParsingpins 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, thestringand UTF-8 overloads agree, a missingresultstill completes, anerrorstatus still rejects with the parsedErrorResponseattached, a null message does not throw out of the entry point — and holds two allocation budgets at 4x the response size. The first measuresRequestManageralone, per thread so the class-parallel run cannot perturb it (1.89x now). The second runs 20 pages throughConnectionover 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.PagedResponseServerreuses one response frame per connection and rewrites the id in place so the server contributes nothing to what the client is measured on
-
errorresponses were deserialized a third time — thestatus == "error"branch ofHandleResponsere-parsed the whole message into anErrorResponseinside atry/catchthat swallowed everything, to build the exception'sResponse. The message had already been deserialized into anErrorResponseat 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
- 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 theNewtonsoft.Json→System.Text.Jsonmigration; affects every release from 10.3.0.0 on.JsonSerializer.Serialize(tx.Meta)threwJsonException: A possible object cycle was detectedfor any transaction whoseAffectedNodescontain anNFTokenPage, which is everyNFTokenMint,NFTokenBurn,NFTokenAcceptOfferandNFTokenModifythat touched a page. Verified against mainnet on all six NFT transaction types — the four above failed,NFTokenCreateOfferandNFTokenCancelOffer(no page in their metadata) went through:- The converter broke its own recursion the way the other polymorphic converters do — strip itself from
options.ConvertersviaJsonSerializerOptionsCache.WithoutConverter<T>and re-enter the serializer. That works only for a converter that is registered in the list.LONFTokenConverteris declared as a[JsonConverter]attribute on theNFTokentype itself (LONFTokenPage.cs), and a converter attached to a type outranks the options list, so System.Text.Json handed the value straight back toWriteno matter what the list looked like. The frame repeated until the writer hitMaxDepth. RaisingMaxDepthis not a workaround: at 64 and 128 it is a catchableJsonException, at 256 the stack overflows and the process dies NFTokenhas two fields, soWritenow emits them directly instead of delegating. The wire shape is unchanged —{"NFToken":{"NFTokenID":"…","URI":"…"}}, the envelopeReadalready looks for — and the documented null behaviour is preserved by honouringoptions.DefaultIgnoreConditionrather than hard-coding one:XrplJsonOptions.Default(WhenWritingNull) omits a nullURI, plain options keep it asnull- The six other converter types that call
WithoutConverter—LOConverter,GenericStringConverter<T>,MetaBinaryConverter,LedgerBinaryConverter,TransactionRequestConverterandTransactionResponseConverter— were audited against the same two conditions — declared as a type-level attribute and re-serializing that same declared type. None hit both.LOConverteris registered in the options list (its one attribute use is property-level) and writes the concrete runtime type;GenericStringConverter<T>,MetaBinaryConverter,LedgerBinaryConverterandTransactionRequestConverterare only ever attached to properties. The three node converters (CreatedNodeConverter,ModifiedNodeConverter,DeletedNodeConverter) do not callWithoutConverterat 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()).TransactionResponseConverteris the one other type-level case, and the same trap was already defused there by theTransactionResponseUnknownsentinel, so that no value ever carries the annotated type at runtime. Nothing else was changed TestULONFTokenConverterhadReadcoverage only, which is how the bug survived. It now pins the written shape, both round trips (URIset and null), null handling underXrplJsonOptions.Defaultand under plain options, a multi-tokenNFTokenPage, and — the regression test proper — serializing aMetacarrying anNFTokenPageinCreatedNode.NewFields,ModifiedNode.FinalFields,ModifiedNode.PreviousFieldsandDeletedNode.FinalFields, since the page can arrive in any of them. All offline, on prepared JSON
- The converter broke its own recursion the way the other polymorphic converters do — strip itself from
- WebSocket message assembly was quadratic in the number of receive chunks —
ReceiveLoopAsyncgrew a multi-chunk message withbyteResult = 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 roughlyk/2times its own length; every intermediate array was well past the 85 KB threshold and therefore landed on the uncompacted large object heap.ledger_dataatlimit=2048is a few megabytes and arrives in dozens of chunks over a real link, which is exactly where the cost concentrates. Chunks are nowBuffer.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 fromArrayPoolrather 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-sizedbyte[]plus the UTF-16 string handed to the callback). Through the full client stack, a 3000-pageledger_datacrawl 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.ReceiveChunkSizewas 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.TestUWebSocketMessageAssemblypins 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 deadtimedOutlocal, declared and tested but never assigned since it appeared, is gone - Request timeout timers outlived their requests —
RequestManager.Resolve/Rejectcalledtimer.Stop().System.Timers.Timerderives fromComponentand carries a finalizer, so every completed request left a finalizable object behind, each of them holding its request's serialized text alive through theElapsedclosure; 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 itsRegistercallback inline, soRejectcompleted 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,Rejecttook its missing-promise early return without removing it, so the entry stayed intimeoutsAwaitingResponsefor the life of the process. TheCancellationTokenRegistrationleaked on the same path, its assignment toTaskInfohappening afterDeletePromisehad already run. Both factories now check whether the promise survived and clean up after themselves; timer removal moved intoDisposeTimeout, which is also called on the early returns ofResolveandRejectand so closes the narrow race with a concurrent cancellation as well.TestURequestManagerCancellationpins 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 gone —
Resolve,RejectandObserveTaskExceptionreached forTrySetResult,TrySetExceptionandTaskthroughGetType().GetMethod(...)+Invokeon every single response.TaskInfonow carries typedSetResult/SetExceptiondelegates and theCompletionTaskitself, wired when the request is created. The properties were added rather than substituted:TaskInfois public, so instances built outsideRequestManagerkeep the old reflective path - Dead
tasksfield removed fromXrplClient—private readonly ConcurrentDictionary<int, TaskInfo> taskswas never assigned and never read, so it was permanently null; a leftover from when the client tracked pending requests itself, whichRequestManagerhas done for a long time
-
MPT path steps (
0x40) —PathSetonly knew the three classic hop-type bits (0x01account,0x10currency,0x20issuer). rippled addedSTPathElement::TypeMpt = 0x40in 3.2.0, so a hop can now carry a 24-byteMPTokenIssuanceIDinstead of a currency. The gap was silent in both directions:FromParsermatched none of its masks on a0x40byte, produced an empty hop and left the 24 MPTID bytes unread — every following byte was then parsed at the wrong offset — whileSynthesizeTypehad no way to emit the bit at all. Now handled end to end:PathHop.MptIssuanceId(Hash192) with a second constructor,HasMpt()and theTypeMpt/TypeAllbyte constants;currencyandmpt_issuance_idin one step throwInvalidJsonException, matching rippled, which throwsbad path element: MPT and Currency- serialization order mirrors
STPathSet::add()— type byte, then account(20), MPTID(24), currency(20), issuer(20) FromParsernow rejects what rippled rejects: a type byte carrying bits outsideTypeAll(0x71), currency together with MPT, and an empty path — a leading or doubled0xFFseparator, 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 readToBytesthrows on an emptyPathinstead of writing it away silently — the encoding side of the same asymmetry- a non-string
mpt_issuance_idraisesInvalidJsonExceptioninstead of a rawInvalidOperationExceptionfrom the JSON node, matching howAmountandIssuereport the same mistake Payment.IsPathStepacceptsmpt_issuance_idas a valid step asset and now follows rippled'stoStrand()rules instead of the looserxrpl.jsport it was:accountcombined withcurrency,issuerormpt_issuance_id, andcurrencycombined withmpt_issuance_id, are alltemBAD_PATHupstream and are rejected before the transaction is sent.xrpl.jsisPathStepstill acceptsaccount+ asset — that is a gap on their side, not a compatibility requirementTestUPathSetpins the layout of both the classic and the MPT hop against rippled's, plus the round trip and every rejection path;TestUPathSteppins the step-validation rules againsttoStrand()- Note this is ahead of the network:
MPTokensV2is not enabled on mainnet (and not currently inMajorities), so MPT hops cannot yet appear in a validated ledger.xrpl.jsandxrpl-pydo not handle0x40either
-
Path.MPTokenIssuanceID— thempt_issuance_idkey of a path step was missing from the model, so a step read fromripple_path_find/path_findcould not be represented, let alone sent back -
Path.TypeHexremoved (breaking, no[Obsolete]grace period, consistent with the 10.11.0.0 removal of ledger-object properties that are not protocol fields) — rippled removedtype_hexfromSTPath::getJsonin 1.7.0 (commitf0724694); only the unusedJSS(type_hex)declaration survives injss.h. No server has emitted the field for five years, so the property could never be anything butnull— there is nothing to deprecate, only dead surface to delete. Verified against mainnet: 19 transactions carryingPathsacross three consecutive ledgers, 21 path steps,typepresent in all 21 andtype_hexin none, plusripple_path_findons1/s2.ripple.com. A response from a pre-1.7.0 server still deserializes — the unmapped key is ignored, whichTestUPathStepIgnoresLegacyTypeHexpins -
Path.Typeis a[Flags]enum now (breaking) — the hop type is a bitmask, but the model spelled it as a bareint?, so callers compared against magic48. It is nowPathStepType(Xrpl.Models.Enums), matching how ledger objects already type their flags (AccountRootFlagsand eight more) and howTransactionType/LedgerEntryTypealready exist model-side next to their codec counterparts. The enum is deliberately not shared withXrpl.BinaryCodec: the codec stays byte-level —PathHop.Typeis abytesynthesized from theTypeAccount/TypeCurrency/TypeIssuer/TypeMptconstants — so the model does not drag a codec namespace into its public surface. The wire format is unchanged:XrplJsonOptionsdeliberately registers no globalJsonStringEnumConverterbecause XRPL protocol enums are numeric, and a value carrying a bit the enum does not declare survives deserialization untouched, whichTestUPathSteppins along with the numeric wire form. The one behavioural loss:"type":"48"sent as a string no longer parses, sinceNumberHandling.AllowReadingFromStringdoes not apply to enums; rippled always sends it as a number -
Path.Typedocumented 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'sSTParsedJSONreads onlyaccount/currency/mpt_issuance_id/issuerfrom a submitted step, and the binary codec derives the byte from the fields actually present. Pinned byTestUPathSetHopTypeIsSynthesizedNotReadFromJson— droppingtype, 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
developsnapshot (breaking). The stand moves from 3.2.1 to the 3.3.0 release image, which activatesBatchV1_1,Sponsor,PermissionDelegationV1_1,DynamicMPT,ConfidentialTransferandfixCleanup3_3_0at genesis, so 44 previouslyAmendmentGuard-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 indefinitions.jsonfield codes alone, which is why nothing caught it earlier — see the Definitions Watch note below:- DynamicMPT:
MutableFlagsis nowImmutableFlags, with the meaning inverted. SameUInt32field, same nth 53, same bit values — but a set bit no longer means "this may be changed later", it means "this is frozen forever" (rippledMPTokenIssuanceSet::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 backtecNO_PERMISSION, freezes silently succeeded, andledger_entryreturned anImmutableFlagskey the model did not bind.MPTokenIssuanceCreateMutableFlagsandMPTokenIssuanceSetMutableFlagsare replaced by a singleMPTokenIssuanceImmutableFlags(tif*, aliasing thelsif*ledger constants) shared by both transactions andLOMPTokenIssuance.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 throughFlags = tfMPTSetCanLock | tfMPTSetRequireAuth | tfMPTSetCanEscrow | tfMPTSetCanTrade | tfMPTSetCanTransfer | tfMPTSetCanClawback | tfMPTSetCanHoldConfidentialBalance(0x04–0x100), added toMPTokenIssuanceSetFlagsnext to the existingtfMPTLock/tfMPTUnlock.ImmutableFlagson the same transaction now does the opposite job — it freezes capabilities and fields, OR-ed into the ledger object, never cleared - Sponsor:
SponsorshipSettakes deltas, not absolute values.FeeAmountandRemainingOwnerCountare fields of theSponsorshipledger object only; the transaction carriesFeeAmountDelta(Amount, nth 34) andRemainingOwnerCountDelta(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::applyTemplaterejects any field outside the format withinvalidTransaction — Field 'FeeAmount' found in disallowed location, which is what 13 of the 17 failures were.SponsorshipSet.FeeAmount/RemainingOwnerCountbecomeFeeAmountDelta(Currency) /RemainingOwnerCountDelta(int?, signed — a negative delta reclaims budget);LOSponsorshipis unchanged, it already matched the object. Client-side validation followsSponsorshipSet::preflight: a delta must be non-zero,FeeAmountDeltamust be XRP, andtfDeleteObjectmay not carry any of the three modification fields definitions.json+ the three generatedField.*partials carry the renamed and the two new fields;Common.TryGetInt32was added for the signed delta, the codec already hadInt32Type
- DynamicMPT:
-
The nightly pin now has a watcher —
nightly-pin-watch.yml, weekly. The pin is whatdefinitions-watchsees 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.shdoes the move: newestxrpldbuild from the nightly apt channel,ARG XRPLD_VERSIONrewritten,rippled.batchv11.cfgregenerated 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.--checkreports the pin, the newest build and the pin's age without touching anything. Both timestamp formats are compared by their commonYYYYMMDDHHMMprefix- 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_dispatchtakes aforceinput 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 — anode-onlyfield there is the SDK being behind develop, reported instead of hidden - credentials, idempotency and the tracking-issue fallback follow release-watch exactly, including the one-notification-per-failure-streak rule
-
Cancellation no longer disappears into the autofill fee fallbacks —
FetchCounterpartySignerCountandFetchLoanwrap their client call in a broadcatch, 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 anOperationCanceledExceptionraised 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 carrywhen (!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 noFeebehind, an unreadable Loan object must still fall back -
MPTokenIssuanceSetvalidation reports a malformedFlagsasValidationException— it went throughConvert.ToUInt32, which throwsFormatExceptionorInvalidCastExceptionon a non-numeric value, while theImmutableFlagscheck two lines below reportsValidationExceptionlike the rest of the validators. Callers catchingValidationExceptiondid not catch the other two -
The conformance fixtures are re-pinned to the 3.3.0 tag —
transactions.macroandLedgerFormats.hnow come from the release commit (00a178fb) instead of a Julydevelopsha and3.3.0-rc1;ledger_entries.macrostays ondevelop(9859e5ce) for the reason its.refalready gives —sfLEVersionexists only there. Both macro files are byte-identical to upstream and re-verifiable with thecurl … | diffline in each.ref. This is what makes the guards test against the version CI actually runs:RippledLedgerFlags.Parselearned to read thelsif*values. In 3.3.0 they are no longer aLEDGER_OBJECT(MPTokenIssuanceMutable, …)block but plaininline constexpr std::uint32_tconstants next to the macro list, so the flag guard would have quietly lost that enum entirely. They are reported under a syntheticMPTokenIssuanceImmutableobject, and a parse that finds none of them now throws instead of returning a thinner table
-
Why the weekly Definitions Watch stayed green through all of this —
definitions-watch.ymlraises a stand fromdocker-compose.batchv11.yml, i.e. the pinned nightlyXRPLD_VERSION. While the pin is stale the monitor diffsdefinitions.jsonagainst a build older than the one CI runs, and reports "in sync" about the past. The pin needs to move with every stable bump, not only when a new amendment is wanted -
Autofill covers the three remaining transactors with a special base fee —
Transactor::calculateBaseFeeis 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 withtelINSUF_FEE_Pinstead of being topped up. Each is verified against the rippled source rather than inferred from the field layout:LoanSetno longer assumes a single counterparty signature. The old formula was a flatbaseFee * 2, correct only when the counterparty signs with its master key.LoanSet::calculateBaseFeecharges one base fee per entry ofCounterpartySignature.Signers, so a counterparty that multi-signs made the autofilled fee too low. When the signature is already attached —LoanSigningHelperran 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 signatureLoanPaycharges per five payments processed.LoanPay::calculateBaseFeemultiplies the whole Transactor cost — signatures included — by one increment perkLoanPaymentsPerFeeIncrement(5) payments the transaction is expected to make, capped atkLoanMaximumPaymentsPerTransaction / 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 theLoanobject, derives the per-payment amount asroundPeriodicPayment(PeriodicPayment, LoanScale) + LoanServiceFee— rounding up to whole units for XRP and MPT, to a multiple of10^LoanScalefor IOUs, asroundToAssetdoes — and divides the transactionAmountby it. Every path rippled short-circuits is mirrored:tfLoanFullPaymentandtfLoanLatePaymentdo one set of calculations,PaymentRemaining <= 5needs no increments, and an unreadableLoanobject 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 ownAmount, 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) callTransactor::calculateBaseFeewithkConfidentialFeeMultiplier= 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
OnConnectedhandler failures now back off — the give-up branch added earlier is bounded byMaxReconnectAttempts, but only whenStopAfterMaxAttemptsis 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,StopReconnectLoopzeroes_reconnectAttempts, a fresh sequence zeroes it again, andCalcBackoffderives the delay from that counter alone. The client therefore repeated connect → handler failure → teardown at a constantReconnectBaseDelayforever — a sustained connection load on exactly the node that cannot serve requests yet.StartReconnectLoopnow 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.TestRepeatedOnConnectedFailuresBackOffpins it; reverting the fix makes that test show ~100 reconnects in 20s at a flat ~200ms interval -
_reconnectCtsisvolatile— the reconnect loop compares it by reference to decide whether it still owns the reconnect state, whileStopReconnectLoop,StartReconnectLoopandRetireCurrentSessionAndReconnectAsyncwrite 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 alreadyvolatile -
Lending guide corrected — the
Loan Fieldstable inLendingProtocol-Guide(both languages) listed four names the ledger object does not have:Account(the borrower is inBorrower), plusCounterparty,PrincipalRequestedandPaymentTotal, which are fields of theLoanSettransaction. AfterPrincipalRequestedwas removed fromLOLoanin this release the guide would have promised a property that no longer exists. Fixed, with a note pointing the three transaction fields atLoanSet -
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 derivedJsonSerializerOptionsfrom 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 200account_objectspages of 200 entries, 456 ms / 47 MB allocated before against 217 ms / 29 MB after.JsonSerializerOptionsCachebuilds 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.DetermineTyperesolved an unrecognizedLedgerEntryTypetoLOAccountRoot.Enum.TryParsewritesdefault(TEnum)into itsouton failure andAccountRootis the zero value, overwriting theUnknownthe 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 toBaseLedgerEntrythe wayLedgerEntryTypeConverterandNodeConverterBasealready do. Pinned from both entry points — a bareBaseLedgerEntryand anaccount_objectspage- the
//todo change from class to interface and parse same as transactionResponseonAccountObjects.AccountObjectListis dropped rather than implemented. The parsing half has been true sinceLOConverterwas registered globally, andBaseLedgerEntryhas to stay a concrete class precisely because it is theUnknownfallback — an interface would need a sentinel type, which is whatTransactionResponseUnknownexists to be. Nothing pinned the polymorphism for the response model itself;TestUAccountObjectsPolymorphismnow does
-
Test-side fixes —
TestUtils.GetFreePortnever 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);TestUChangeServerFailurechecks the port is still free right before starting the second mock, so the remaining external race fails fast with a clear message;RippledLedgerFlags.Parsethrows on a ledger object declared twice, matchingRippledLedgerEntryFormats.Parse; the fixture entries in the test.csprojuseNone Updateinstead ofNone Include, since the SDK's default glob already includes them -
TestULedgerEntryFieldsConformance— the third conformance surface, completing the set next toTestUTxFormatConformance(transaction fields) andTestULedgerFlagsConformance(ledger flags).ledger_entries.macrois the only place the protocol states which fields belong to which ledger object —definitions.jsoncarries 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 howLOAccountRootwent withoutWalletLocator/WalletSizeuntil a manual pass, and howsfLEVersionhad 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, unlikeLedgerFormats.h: the models track develop for fields, andsfLEVersionexists 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,SponsorfromLedgerFormats::getCommonFields()) are excluded on both sides, mirroring how the TxFormat guard treatscommonFields;[JsonIgnore]properties (computed helpers likeDataParsed,MPTokenMetadataRow) never reach the wire and are excluded too - verified by mutation: renaming a field's
JsonPropertyNamemakes it report both halves (Loan.Borrower … missing from LOLoanandLOLoan.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 inertConnectionOptions). None of them could ever hold a value: rippled builds each object from a fixedSOTemplate, 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: aVaultCreatecarryingData,AssetsMaximumandDomainIDsucceeded, the first two came back on the object,DomainIDdid not, and it turned up on the linked shareMPTokenIssuanceinstead — exactly what the macro comment (no PermissionedDomainID ever (use MPTIssuance.sfDomainID)) andVaultCreate.cpp(.domainId = tx[~sfDomainID]) describeLOLoan.PrincipalRequested— a field of the LoanSet transaction, not of the object: a real loan created withPrincipalRequested = 10000000stores it asPrincipalOutstanding, and the object carries no such fieldLOCredential.OwnerNode— Credential hangs in two directories and usesIssuerNode/SubjectNode. Zero-valued directory hints are serialized (a Loan object returns"OwnerNode":"0"), so its absence is real, not a default being omittedLONFTokenPage.NFTokenPage,LOAmm.LedgerCurrentIndex,LOAmm.Validated— the last two are fields of theamm_inforesponse envelope (ledger_current_index,validated, snake_case), not of the AMM object;LOAmmis only ever deserialized as a ledger object, andamm_infohas its ownAMMInfomodel
-
LOAmmfixes — two bugs the guard surfaced:AMMAccountnever deserialized: the AMM object's field isAccount, and the property had no[JsonPropertyName], so it silently stayed null on every AMM object ever read. Now mapped toAccount; the property name is unchanged, so no call site breaks- the constructor set
LedgerEntryType = LedgerEntryType.AccountRoot— an AMM object identified itself as an AccountRoot. NowLedgerEntryType.AMM
-
Fields declared by the protocol but missing from the models —
PreviousTxnID/PreviousTxnLgrSeqonLOAmm,LOAmendments,LODirectoryNode,LOFeeSettingsandLONegativeUNL. Both areSoeOptionalon 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 intodevelop07/30/2026, reported by protocol-watch).UInt8nth 6,SoeDefaultonltVAULT: it marks which accounting scheme a vault follows. Vaults created before cash-basis accounting was activated carry noLEVersionat 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 generatedField.Uint8entry. Both are required:definitions.jsonis not read at runtime, it is the input toTools/GenerateEnums, so a field added there alone travels nowhere.TestULEVersion_BinaryRoundTripis what proves the round trip actually works rather than that the JSON was editedLOVault.LEVersion(uint?, matching the other UInt8 fields of that object) plus aVaultVersionenum naming the two values the protocol defines so far (Legacy= 0,CashBasis= 1)TestULOVault_LEVersion_Deserializecovers both shapes — the field present, and a legacy vault without it deserializing tonullXrpl.BinaryCodecbumped to 10.11.0.0, aligned withXrplrather 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.AddressCodecandXrpl.Keypairsare 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(tag3.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:MPTokenIssuanceSetwithFlags = tfMPTSetCanHoldConfidentialBalancemoves the issuance fromFlags = 0toFlags = 128MPTokenFlags+lsfMPTAMM(0x4) — a much older gap: the flag is present as far back as 3.2.1.AMMCreatesets it together withlsfMPTAuthorizedto implicitly authorize an MPT asset for the AMM pseudo-accountLOLoan.Flags— the Loan ledger object had noFlagsproperty at all (andBaseLedgerEntryhas none either), solsfLoanDefault/lsfLoanImpaired/lsfLoanOverpaymentwere unreadable through the typed model: the default and impairment state of a loan could not be observed at all. Added as a typedLoanFlags?together with the enum- new
SignerListFlags(lsfOneOwnerCount) andDirectoryNodeFlags(lsfNFTokenBuyOffers/lsfNFTokenSellOffers) — both objects exposeFlagsas a rawuintand keep doing so (changing the property type would be breaking); the enums give consumers named constants to test bits against instead of magic numbers. TheLODirectoryNode.Flagscomment claiming "the protocol defines no flags for DirectoryNode objects" was false and is corrected
-
TestULedgerFlagsConformance— the guard that would have caught all of the above —LedgerFormats.his the only place the protocol states whichlsfflags belong to which ledger object (definitions.jsoncarries field codes and entry types, but no flag values). Nothing checked it, which is howlsfMPTAMMsurvived several releases. The new test is the ledger-side counterpart ofTestUTxFormatConformance:Tests/Xrpl.Tests/Fixtures/LedgerFormats.his vendored byte-identical and pinned by sha inLedgerFormats.h.ref, verifiable with a plaincurl … | diff. Pinned rather than live for the same reason astransactions.macro: upstream drift is protocol-watch's job, and a network-backed test would go red on Ripple's release schedule instead of oursRippledLedgerFlagsparses theLEDGER_OBJECT/LSF_FLAGmacro text and fails loudly — an unknownLSF_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/tifprefixes, solsfMPTLocked≡MPTLockedand rippled'slsifMPTCanLock≡ the SDK'stifMPTCanLock(rippled itself aliasestifX = lsifXinTxFlags.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-watchnow watchesinclude/xrpl/protocol/LedgerFormats.has 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 whylsfMPTAMMwent 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.
AmendmentGuardgains theDynamicMPTid (it matches whatgenerate-amendments.shalready writes into the nightly stand's[amendments]), andTestIDynamicMPTcovers the amendment end to end, each test reading the result back from the ledger object rather than trustingEngineResult:ImmutableFlagsset atMPTokenIssuanceCreatereachLOMPTokenIssuanceunchangedMPTokenIssuanceSetmutatesTransferFeeandMPTokenMetadataon an issuance that froze neither, and leavesImmutableFlagsunset (doApplyonly ORs that field when the transaction carries it)tfMPTSetCanLockraiseslsfMPTCanLockon an issuance created without that capability- a mutation of a frozen field is rejected with
tecNO_PERMISSIONand 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 — hencetfMPTCanTransferat creation in the fee test:preclaimrequireslsfMPTCanTransferto 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
DynamicMPTasSupported::No) and runs for real on the nightly stand
-
An exception from an
OnConnectedhandler no longer kills the client forever —Connection.OnceOpencaught anything thrown by a consumerOnConnectedhandler and calledDisconnect(), i.e. the user disconnect path: it set_permanentlyDisconnected = trueand calledClearReconnectState(). After that the client was dead — the reconnect loop was never restarted, no new socket was ever opened,OnConnectednever fired again, and every later request threwNotConnectedException("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.
OnConnectedis 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 firstsubscribeafter the reconnect runs intoRequestTimeout(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.
OnceOpenclears 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, onConnect()and onChangeServer()); once they reachMaxReconnectAttemptswithStopAfterMaxAttemptsset, the client gives up deliberately — an immediate, actionableNotConnectedExceptioninstead of a silent five-minute wait — andConnect()clears the counter so recovery stays possible. WithStopAfterMaxAttempts = falseit keeps retrying, which is what that option asks for - The cause is now observable. The exception is surfaced through
OnErrorwitherrorMessage = "connectHandlerError"(the same shape already used for stream-handler failures) and throughOnConnectionStatus— previously the reason the client died was reported nowhere at all TestUOnConnectedHandlerFailurepins all four properties against the mock rippled: a transient failure recovers and the client is usable again, the failure is reported throughOnError, and a permanently failing handler stops instead of looping
- The trigger is the most ordinary event there is — a node restart.
-
ChangeServerto 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).ChangeServerset the global_isIntentionalDisconnectflag to filter late callbacks from the socket it was retiring, and that flag was only ever reset inOnceOpen. If the new server never came up,OnceOpennever ran:OnConnectionFailedthen read the failure of the new connection as a user disconnect, reported"Connection closed permanently.", started no reconnect loop, and every later call — includingChangeServeritself — 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 (_userInitiatedSocketsplus 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 aChangeServerafter a userDisconnect()is not suppressed by the leftover either.TestUChangeServerFailurepins both cases: the client reaches the new server once it appears, with and without a precedingDisconnect() -
The reconnect loop no longer writes to a reconnect session it no longer owns —
StopReconnectLoop()cancels the loop's token without awaiting the loop, so a retired loop could still reach its body or its tail after a replacement had been installed and clear the live loop's_reconnectMode, reset its_reconnectAttemptsor dispose its_reconnectCts. Pre-existing (RetireCurrentSessionAndReconnectAsynchas always retired loops this way), but the handler-failure path above makes it far more reachable, soReconnectLoopAsyncnow takes theCancellationTokenSourceit owns and touches shared state only while that source is still the active one -
WaitForConnectionAsyncnow rechecks the permanent-disconnect flag on every iteration, not only once on entry. A caller already blocked there when the client is disconnected — byDisconnect()from another thread, or by the give-up path above — used to sit out the wholeConnectionAcquisitionTimeout(default five minutes) and then receive a genericTimeoutException. It now returns the actual reason immediately as aNotConnectedException -
WebSocketClient.SendMessageAsyncno longer swallows send failures silently — it isasync voidand is invoked withoutawaitfromConnection.WebsocketSendAsync, so a failed send could be reported to nobody: the pending request simply sat there until its 40-secondRequestTimeoutexpired. The socket's error callback (previously dead code — nothing ever invoked or wired it) now carries the exception toConnection.OnErrorwitherrorMessage = "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 byRequestTimeout— but the cause is no longer invisible during diagnosis
-
ConnectionOptions.authorizationdid nothing — now it does — the option was public onXrplClient.ClientOptionssince the xrpl.js port, butConnection.CreateWebSocketwas a block of commented-out JS pseudocode ending inWebSocketClient.Create(url); // todo add options, andWebSocketClienthad no parameter to receive them. Nothing the caller set onauthorization,headers,proxy,trustedCertificates,key,passphraseorcertificateever reached the socket:authorizationnow producesAuthorization: Basic base64(value)on the WebSocket upgrade handshake, matching xrpl.jscreateWebSocket— the value is the rawuser:passwordpair, the SDK does the base64headersare put on the handshake as-is; the type changed fromDictionary<string, object>toDictionary<string, string>to match xrpl.js and drop theToString()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.SetRequestHeaderis guarded byOperatingSystem.IsBrowser()the same wayKeepAliveIntervalalready was proxy,proxyAuthorization,trustedCertificates,key,passphrase,certificateand the unusedtrace/Tracepair 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 acrossClientWebSockettargets, 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-HTTPonRequest()path, whileonHandoff()upgrades WebSockets without it. A port stanza'suser/passwordtherefore only guards HTTP JSON-RPC.authorizationis 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_passwordtravel inside the request JSON, not in a header. Without them, a port that setsadmin_user/admin_passwordrejectsledger_accept,stop,connectand friends outright —forbidden/Bad credentials.— regardless of the client's IP, becauserequestRolereturnsRole::FORBIDrather than demoting the client to guest. Both must be set for either to be sent, mirroring rippled's own check (a matchingadminnet and correct credentials)- injected into the serialized request rather than into the request object, so the credentials never reach the
TimeoutExceptionmessage that consumers log —TestAdminPasswordIsNotLeakedIntoTimeoutMessagepins that RequestManager.CreateRequest/CreateGRequesttake the credentials as a trailing optional parameter, so existing positional call sites are unaffected
- injected into the serialized request rather than into the request object, so the credentials never reach the
-
Coverage —
TestUAuthorizationasserts against the raw HTTP upgrade text captured by a loopback socket server: Basic header present and correctly encoded, custom headers present, and noAuthorizationheader when the option is unset.TestIAdminCredentialsruns against a new[port_ws_admin_auth]stanza on the standalone stand (port 6007,admin_user/admin_passwordset) and checks both directions —ledger_acceptrejected withforbidden/Bad credentials.without credentials, accepted with them. The port is separate fromport_ws_adminso the rest of the integration suite is untouched -
Transaction fields declared by the protocol but missing from the models —
TxFormatlisted them and the binary codec knew them, so the values travelled fine throughDictionary<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 ofTxFormatagainst 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+DelegateandOperationLimit— both are rippled common fields (TxFormats.cppcommonFields), valid on every transaction type, so they belong on the shared base rather than on individual transactions.Delegateidentifies a transaction submitted under DelegateSet permissions (previously readable only from raw JSON, thoughBatchUtilsalready honored it when collecting required batch signers).OperationLimitis 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 plainAccountSetAccountSet/AccountSetResponse+WalletLocatorandWalletSize— both still stand in rippled's AccountSet format (transactions.macro).WalletSizeis legacy and not acted on by the transactor; it is exposed so a transaction carrying it survives a round tripValidateBaseTransactiontype-checks the two new common fields, as it already does for every other common field;ValidateAccountSetdoes the same for the two new AccountSet fields —WalletSizeas a UInt32, andWalletLocatoras a 256-bit hex value, which is the rulesfWalletLocator'sHash256type implies and the one the SignerListSet validator already applies to aSignerEntry's WalletLocatorTargetdeliberately not added — it is not a protocol field:sfTargetis retired (AccountID nth 7 is marked unused insfields.macro, and the name is absent fromdefinitions.json), and since the TicketBatch amendment rippled's TicketCreate carries onlysfTicketCount. The staleTarget/Expirationentries were removed fromTicketCreateinTxFormat;Field.Targetstays in the codec so historical blobs still decodeTestUTransactionProtocolFieldspins the whole cycle — deserialization,ToJson/ToDictionaryround 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
-
TxFormatbrought into full conformance with rippled, and held there — the table is inert at runtime (TxFormat.Validateis not on the signing path; the codec serializes fromdefinitions.json), so wrong entries produced no symptom and nothing in the suite noticed. A field-by-field diff against rippledtransactions.macrofound 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 thePaymentChannelClaimentry above them (Channel/Amount/Balance/Signature/PublicKey). NowCheckCreate=Destination+SendMaxrequired,Expiration/DestinationTag/InvoiceIDoptional;CheckCash=CheckIDrequired,Amount/DeliverMinoptional;CheckCancel=CheckIDrequiredNFTokenMint— was missingAmount/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 otherOracleSet— droppedBaseAsset/QuoteAsset/AssetPrice/Scale, andSignerListSetdroppedWalletLocator: in both cases these are members of a nested object (PriceDataSeriesentries,SignerEntry) that had been hoisted to the top levelVaultCreate— droppedAmount, which is not a field of that transactionTestUTxFormatConformancenow diffs every one of the 82 formats against a vendored, ref-pinned copy oftransactions.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.macrois 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 unknownSoe*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) —sfInvoiceIDis aHash256,Payment.InvoiceIDwas alreadystring, andValidateCheckCreatealready rejected anything but a string. The typed property wasuint?, 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 correctedCheckCreateformat -
Integration coverage for the corrected field sets (
TestIProtocolFieldSets, standalone stand) —TxFormatitself cannot be exercised end-to-end, so these pin the claim underneath it against a real node:CheckCreatecarryingExpiration/DestinationTag/InvoiceIDlands and theCheckobject reads them back;CheckCashsettles through the previously untestedDeliverMinbranch;NFTokenMintwithAmount/Destination/Expirationcreates the mint-time sell offer; and anAccountSetwithWalletLocator/WalletSize/OperationLimitsurvives a full ledger round trip back into the typedAccountSetResponse— the end-to-end proof for the model work above.Delegateis covered byTestDelegatedPayment_DelegateFieldSurvivesTheLedgerRoundTrip(amendment-gated onPermissionDelegationV1_1, so it runs on the nightly stand): the owner grants the Payment permission, the delegate signs a Payment whoseAccountis the owner and whoseDelegateis itself — without the field rippled would reject the signature outright — and the transaction reads back into the typed model withDelegateset, both directly and throughITransactionCommon -
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. FixedTask.Delaysleeps 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):
ValidateCheckCreatenow enforcesInvoiceIDas a Hash256, not merely as a string.sfInvoiceIDis a 256-bit hash and this same release added exactly that rule forWalletLocatorinAccountSetandSignerListSet, soCheckCreatewas the odd one out: a malformed value passed validation and only blew up later inside the codec, reporting an encoding error instead of aValidationException. 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.cfgsetsadmin = 0.0.0.0on 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 chainsufwmanages TestIConnectionStates— both reconnect-exhaustion tests discarded theTask.WhenAnywinner, 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 attemptedTestIProtocolFieldSetssetsExpirationon the mint-time NFT offer but never checked it read back; asserted now, closing the last unverified field of the correctedNFTokenMintset- 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 redundantLinkon the vendored fixture is dropped
-
Fix
account_txlosing the payment amount and, on API v1, the whole transaction — a silent regression introduced by the 10.3.0.0Newtonsoft.Json→System.Text.Jsonmigration; affects every release from 10.3.0.0 on:Payment/PaymentResponse.DeliverMax— the private set-only alias that maps API v2'sDeliverMaxontoAmountwas carried over from Newtonsoft (which deserializes attributed non-public members) butSystem.Text.Jsonskips non-public members without[JsonInclude]. Every Payment read throughAccountTransactions,TxV2or the transaction streams came back withAmount = null— no exception, no diagnostic.Tx()was unaffected because it pinsApiVersion = 1, andmeta.delivered_amountkept parsing correctly, which is why the loss went unnoticed. The alias stays set-only, soDeliverMaxis still never serialized back outTransactionSummarynow accepts both envelopes: rippled wraps the transaction intx_jsonunder API v2 and intxunder API v1 — onlytx_jsonwas mapped, soTransactionwasnullfor the entire history wheneverApiVersion = 1was requested.HashandLedgerIndexlive inside the envelope under API v1 and fall back to it accordingly (previouslyHashcame back empty, breaking hash-based lookups over the returned list)- Regression suite
TestUAccountTransactionsEnvelopepins both wire shapes against trimmed captures of real testnet responses — XRP and issued-currencyDeliverMax, both envelopes, and the guarantee thatDeliverMaxnever reaches outgoing JSON
-
GetDomainAccesssugar helper — client-side implementation of thedomain_accesscheck proposed in XRPLF/rippled#7743: answers whether an account can use a permissioned domain (permissioned DEX, vaults) and why not. Oneledger_entrydomain lookup plus up to 10 parallel keyletledger_entrycredential lookups, all pinned to the same validated ledger; result mirrors the proposed API (HasAccess+InvalidCredentialswithAccepted/Expireddiagnostics, empty list = no matching credential). Semantics match rippledcredentials::validDomain/checkExpired: lsfAccepted required, expired only when close time is strictly pastExpiration, no owner shortcut, client-side expiry check (rippled deletes expired credentials lazily)
- 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 fromFromBytesToHex/FromHexToBytes); canonical string-level:Xrpl.Utils.StringConversion(+Xrpl.Models.Utils.HexStringHelperfor validated/padded VL fields) - Removed: the global-namespace
ExtensionHelpersclass fromXrpl.AddressCodec(leakedToHex/FromHexinto every consumer's scope), the byte-identicalXrpl.Client.Extensions.ExtensionHelpersduplicate (the CS0121 ambiguity trap withStringConversion), dead internal copies inXrpl.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), OracleProvider/AssetClass/URI(Blob fields per rippledstrHex), cross-chain payment memos.AssetPricekeeps rippled's lowercase UInt64 emission. Transaction bytes, signatures and hashes are unchanged — hex decoding is case-insensitive on both sides HexStringHelper.FromHexgainstrimTrailingNulls(defaulttrue;FromHexStringpassesfalseso 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
TestUHexHelperslocks the unified behavior (case, null-trim, anchoring, round-trips)
- Canonical byte-level pair:
- Unified signing & submission for sponsored transactions (#43) — the standard
Sign/SubmitAndWaitnow handle XLS-68 end-to-end, no helper choice required:Signroutes by role: a wallet matchingtx.Sponsorproduces the sponsor co-signature; the submitter path preserves an existingSponsorSignatureand guards against aSigningPubKeymismatch.multisign: trueis untouched — Signer entries are section-agnostic per rippledSTTx::checkMultiSign(identical preimage fortx.SignersandSponsorSignature.Signers), so the role is decided at composition timeSignatureComposer.ComposeSignatures(offline, explicit sponsor signers) andclient.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 withoutSponsorSignaturetriggers a one-RPC pre-check of the Sponsorship require-sign flags (sponsorPreCheck: falseto skip) client.ComposeSignaturesvalidates SignerList quorum by weights for both sections — readable client-side error instead oftefBAD_QUORUMSubmitAndWaitSponsored(tx, sponseeWallet, sponsorWallet)— the both-keys-local flow in one callSignalso routes the LoanSet borrower automatically: a wallet matchingtx.CounterpartyproducesCounterpartySignature(XLS-66) — all three co-signing mechanisms (Batch/Sponsor/Loan) now share the no-helper-choice entry point- New
SignatureObjectmodel (shared shape ofSponsorSignature/CounterpartySignature/BatchSigner);LOSponsorshipgainsFlags+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 outerSigningPubKey, so sponsor-side signers of a single-main sponsored tx must sign over the submitter's pubkey (SignMultinow 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 innerCounterpartyand the innerSponsorcarrying aSponsorSignaturemarker — so sponsors/borrowers of inner transactions authorize as batch signers through the same standardSign; the sponsor of the OUTER batch (spfSponsorFee) is routed to a regularSponsorSignatureco-signature;ValidateBatchenforces the new rules (nospfSponsorReserveon the outer, no fee sponsorship on inners, no signature material inside inner co-signature markers); live tests: a reserve-sponsored inner TrustSet lands withHighSponsor/LowSponsorset, a fee-sponsored outer batch passes co-signed, and a sponsor authorizing THROUGH ITS SIGNERLIST lands as a nested-multisigBatchSigner.Signersentry (the sponsor-role counterpart of the initiator-roleTestBatchMultiAccountsWithInnerMultiSigncoverage);ValidateBatchalso rejects Loan/Vault inner transactions client-side (rippledkDisabledTxTypes→temINVALID_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.FromJsonTryGetValue parsing, MPT validators mirror rippled preflight (MutableFlagsmasks,TransferFeevs confidential-balances rule),LONFTokenPage.NextPageMindoc, gateway_balances integration test rebuilt on the standalone node - Release-review pass (PR #48):
SignMultipreserves the submitter'sSigningPubKeyfor LoanSetCounterpartymultisign parts (the XLS-66 mirror of the sponsor preimage rule); smartSubmitAndWaitrecognizes a multisigned main signature (Signers) and skips autofill whenever any signature material is present (a co-signature freezes the body);SignatureObjectenforces the two protocol shapes (single vs multisig, no empty/mixed forms) andCombinerejects structurally unsigned material;DomainIDvalidation on MPT issuance transactions (64-char hex; non-zero +tfMPTRequireAuthrequired on Create, zero legal on Set as domain clear — per rippled preflight);Xrpl.BinaryCodecpackage version bumped to 10.8.0 (the codec changed since 10.7.0); Sponsorship guide correctsSponsorshipTransferactors (Create/Reassign are submitted by the sponsee) and documents the sponsee-sideSponsorshipSetdeletion viaCounterpartySponsor; ConfidentialMPT guide describes the integration test accurately (plain issuance, generictem/tecassertion); protocol-watch workflow fails closed on a corrupted baseline, marks removed upstream files and skips duplicate notifications via ahead_shamarker
- Protocol-completeness pass driven by a field-level diff against rippled
develop(server_definitions@8306ac77):definitions.json: addHighSponsor/LowSponsor(XLS-68 RippleState reserve sponsors); fixisVLEncodedonSponsor/Sponsee/CounterpartySponsor(AccountID fields are VL-encoded); alignGenericattributes 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; plusLOAmm,LOEscrow,LOPayChannel,LOSignerList,LOOracle(OracleDocumentID),LONFTokenPage,LOFeeSettings,LODelegatefield gaps - TxFormat: entries for all four MPT transactions
- Fix
Validation.Validatedispatch:NFTokenModifywas routed toValidateNFTokenMint(a valid Modify withoutNFTokenTaxonwas rejected); now callsValidateNFTokenModify - Fix
LOSignerList.SignerListIdnever being populated: the property lacked aJsonPropertyNameattribute and its casing did not match rippled'sSignerListID - Review pass (PR #34): TxFormat corrections — the entry labeled
UNLModifyactually held SetFee's legacy format; relabeled toSetFee(all fee fields optional per rippledttFEE, + XRPFees drops fields), added the realUNLModifyand the missingEnableAmendmententries;AMMDeposit+ optionalTradingFee,VaultDelete+ optionalMemoData(both verified against rippled developtransactions.macro);MPTokenIssuanceSetgains theMPTokenMetadataRow/Metadata(XLS-89) convenience accessors for parity withMPTokenIssuanceCreate - 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.FromJsonnow receives the field'skSmdBaseTencontext (decimal for the five base-ten fields, strict hex otherwise) — the decode-side counterpart shipped in 10.6.0 Autofillfee: account for sponsor multisig per rippledTransactor::calculateBaseFee— each signer nested inSponsorSignature.Signersadds one base fee (a single-signedSponsorSignatureadds nothing)ValidateAccountSet:SetFlag/ClearFlagasf-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
8306ac77with all amendments active
- Sponsored Fees & Reserves (XLS-68,
Sponsoramendment) — merged into rippleddevelopon 07/10/2026 (rippled #7350):- New transaction models
SponsorshipSet(91) andSponsorshipTransfer(90) with tf-flag enums per rippledTxFlags.h;LOSponsorshipledger object (0x90) - Common transaction fields
SponsorandSponsorFlags(SponsorCoverage:spfSponsorFee= 1,spfSponsorReserve= 2) on all transactions - Sponsor co-signing:
SponsorSigningHelper(V1 automatic / V2 parallel combine / V3 sequential) andXrplWallet.SignAsSponsor—SponsorSignatureis an inner not-signing STObject over the same preimage as the main signature, mirroring the LoanSet counterparty pattern
- New transaction models
- 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.jsonsync with rippleddevelop@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 renamedUInt384/UInt512→Hash384/Hash512(ordinals unchanged)- TxFormat: common optional fields
Delegate,Sponsor,SponsorFlags,SponsorSignature; formats for all 7 new transaction types - Integration:
TestISponsorshipgated byAmendmentGuard(Sponsor/ConfidentialTransfer amendment ids added); nightly stand pinned toxrpld 3.3.0-b1@8306ac77with 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;
SponsorSignatureexcluded from the preimage (kNotSigning) but round-trips through the binary codec - Completeness pass over touched ledger objects:
LOAccountRootgains the XLS-68 counters (SponsoredOwnerCount,SponsoringOwnerCount,SponsoringAccountCount) plus previously missingVaultID/LoanBrokerIDback-references;LOMPTokengains the six ConfidentialTransfer balance/key fields;LOMPTokenIssuancegainsDomainID,MutableFlags,ReferenceHolding,IssuerEncryptionKey,AuditorEncryptionKey,ConfidentialOutstandingAmount(+11 ledger-object fields added todefinitions.json) - Fix binary-codec JSON decode of base-ten UInt64 fields (
MPTAmount,LockedAmount,OutstandingAmount,MaximumAmount,ConfidentialOutstandingAmount):Decodenow 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;
TestIConfidentialMPTnegative e2e (bogus proof is rejected by ConfidentialTransfer domain logic, not the parser — proving the node parses our encoding)
- Fix
SignAsBatchPartwithTicketSequence: when the outer Batch used a ticket and had noSequence, the value0was applied only to the signing preimage while the serialized blob omitted the requiredSequence: 0field, producing a malformed transaction on submit. The field is now written into the transaction as well; signatures are unaffected (the preimage already used0). Found by review on the 10.5.0.0 release PR - Add a unit test covering the
TicketSequence-present /Sequence-absent signing path (blob carriesSequence: 0, signature verifies over the zero-sequence preimage) - Correct the
EncodeForSigningBatchXML doc:outerAccountaccepts 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.ymlare now published to127.0.0.1only
- BREAKING: Align Batch (XLS-56) signing with the
BatchV1_1amendment (rippled #6446, merged intodevelop07/01/2026). The signing preimage now includes the outerAccount(20 bytes) and outerSequence(4 bytes) after theBCH\0prefix;NetworkIDis removed from the preimage.XrplBinaryCodec.EncodeForSigningBatchsignature changed to(string outerAccount, uint outerSequence, uint flags, IEnumerable<string> txIDs). Signatures produced by the previous format are rejected by rippled onceBatchV1_1is active SignAsBatchPartsingle-sig now binds the signature to theBatchSigneraccount id (finishMultiSigningDataequivalent); inner multisign bindsowner(20) + signer(20)account ids — both per the audit hardening in BatchV1_1- Reject duplicate
BatchSigneraccounts locally (SortBatchSigners,ValidateBatch) and aBatchSignerequal to the outerAccount— early fail instead oftemBAD_SIGNERfrom the server - BREAKING: Align
DelegateSet(XLS-75) with thePermissionDelegationV1_1amendment — the delegate account field isAuthorize(sfAuthorize), notDelegate:IDelegateSet.Delegate/DelegateSet.Delegate/LODelegate.Delegaterenamed toAuthorize;TxFormatrequiresAuthorize - Add
PermissionValueConverter— rippled returnsPermission.PermissionValueas a name string in JSON responses (a transaction type name or a granular permission likeTrustlineAuthorize); the converter maps names to numeric values (transaction type code + 1; granular table 65537–65548 perpermissions.macro) and accepts plain numbers - Re-enable
TestIBatch(19 tests) andTestIDelegateSet(2 tests) — previously[Ignore]d. NewAmendmentGuardmarks 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(pinnedxrpldnightly from repos.ripple.com),.ci-config/docker-compose.batchv11.yml,.ci-config/rippled.batchv11.cfg(genesis up-votes via the[amendments]section — on rippleddevelopthe[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, commitc92285f1) withBatchV1_1andPermissionDelegationV1_1active: 21/21 integration tests pass; on the 3.2.0 CI image the fullTestIsuite runs 213 passed / 21 skipped / 0 failed
- New package
Xrpl.X402— x402 (HTTP-402) agentic payments client for the XRP Ledger (t54 "XRPL exact scheme"). ADelegatingHandlerthat detects a 402 challenge, builds and locally signs an XRPLPayment(XRP or RLUSD/IOU), and retries with aPAYMENT-SIGNATUREheader. 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,LastLedgerSequencecapped bymaxTimeoutSeconds - Intent binding matches the t54 reference payer:
Payment.InvoiceID = SHA-256(invoiceId), aMemoData= hex(invoiceId),payload.invoiceId, andSourceTagfromextra.sourceTag(configurable viaX402IntentBinding); IOU payments includeSendMax - 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: aRequirePaymentendpoint filter plusLedgerSettlingFacilitator(settles locally) andT54Facilitator(delegates to a t54 facilitator) - Live interop with the t54 testnet facilitator confirmed on-chain for both XRP and RLUSD/IOU (
/verify→isValid:true,/settlesettles)
- Fix thread-unsafe request id assignment in
RequestManager— concurrent requests on a single connection (e.g.Task.WhenAllover severalBookOffers) could collide on the same id and throwResponse with id '$<guid>' is already pendingor drop a pending promise. Removed the sharednextIdfield; each call now generates its ownGuidand registers via a single atomicConcurrentDictionary.TryAdd, enabling parallel requests on one connection - Surface exceptions thrown by stream handlers (
OnLedgerClosed,OnTransaction, etc.) through theOnErrorevent instead of swallowing them into a debug trace — consumer bugs are now observable, while the message loop stays alive and a throwingOnErrorhandler is contained - Clarify in XML docs that
Xrpl.Client.Exceptions.TimeoutExceptionis notSystem.TimeoutException(it derives fromXrplException), to avoid mismatchedcatchclauses
- Fix
IouValue(IOU token amount) parsing to accept a trailing decimal point (e.g."128700."), aligning withxrpl.js/ripple-binary-codecandrippledSTAmountreference behavior — previously the stricter validation regex rejected a value with no digits after the dot, breaking signing of transactions (e.g.AMMDepositvia 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 singleIouValue.ValueRegexconstant inAmountValue.csandExtenstionHelpers.cs - Native XRP (drops) and MPT amount parsing unchanged; mantissa/exponent math,
ToString()output, andToBytes()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 andToBytes()blob) and regression tests for existing values
- Sync
Xrpl.BinaryCodecenums with upstreamdefinitions.jsonfrom xrpl.js - Add 24 missing
TransactionTypeentries: 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
LedgerEntryTypeentries: Bridge, XChainOwnedClaimID, XChainOwnedCreateAccountClaimID, MPTokenIssuance, MPToken, Oracle, Credential, PermissionedDomain, Delegate, Vault, LoanBroker, Loan, DID, NegativeUNL, NFTokenOffer, NFTokenPage - Add 7 missing
FieldTypeentries: Number, Int32, Int64, UInt96, UInt384, UInt512, XChainBridge - Add ~40 missing
Fieldentries across all types; fix incorrect ordinals for DiscountedFee, VoteWeight, HookGrants - Regenerate
EngineResultwith all 189 transaction result codes from protocol spec - Add
terNO_DELEGATE_PERMISSION(-85) todefinitions.json - Mark deprecated entries with
[Obsolete]: HookSet, GeneratorMap, Contract, EnabledAmendments - Refactor
EngineResult,TransactionType,LedgerEntryTypeto partial-class architecture — hand-written infrastructure + auto-generated fields fromdefinitions.json - Add
Tools/GenerateEnums— .NET console tool for regenerating enum files fromdefinitions.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),LOVaultledger object, and integration tests - Lending Protocol (XLS-66d): Add 9 transaction models (
LoanBrokerSet,LoanBrokerDelete,LoanBrokerCoverDeposit,LoanBrokerCoverWithdraw,LoanBrokerCoverClawback,LoanSet,LoanDelete,LoanManage,LoanPay),LOLoanandLOLoanBrokerledger objects, and integration tests - DelegateSet (XLS-74d): Add
DelegateSettransaction model,LODelegateledger object, and integration tests - LedgerStateFix: Add
LedgerStateFixtransaction model and integration tests - Fix
NumberTypeserialization — 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
CounterpartySignatureco-signing support forLoanSet— 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.mdandLendingProtocol-Guide.ru.mddocumentation
- BREAKING: Migrate entire solution from
Newtonsoft.JsontoSystem.Text.Json— all models, converters, client infrastructure, wallet signing, binary codec - BREAKING: Remove
dynamickeyword from all production code — replace withobject,JsonNode,JsonElementfor iOS Full AOT compatibility - BREAKING: Remove
Newtonsoft.JsonNuGet dependency from all projects (Xrpl,Xrpl.BinaryCodec,Xrpl.AddressCodec,Xrpl.Keypairs) - Add centralized
XrplJsonOptions.Defaultwith 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/JArray→JsonNode/JsonObject/JsonArrayin wallet signing, batch transactions, signer utilities - Migrate all
JsonConvert.SerializeObject/DeserializeObject→JsonSerializer.Serialize/Deserialize - Add
ITransactionRequest.ToDictionary()helper for safeSystem.Text.Jsonround-trip in tests - Fix
SerializedType.ToJson()return type —object→JsonNodeto matchISerializedTypecontract - Fix
ServerFeatures.FeatureInfo.Count—[JsonPropertyName("count")]was inside XML doc comment, not applied to property - Fix
ChannelAuthorize.RippleAmountsetter —Convert.ToUInt32→Convert.ToUInt64to prevent overflow at > 4294 XRP - Fix
AccountingStateInfo.Duration—duration_usfield was parsed as milliseconds instead of microseconds (1000x inflation) - Fix
LedgerTransaction.CloseTimeIsoandLOLedger.CloseTimeIso— addFromStringDateTimeConverterfor consistent ISO 8601 parsing - Fix
CredentialQuery.CredentialTypewire field —credentialType→credential_type - Fix
Amount.FromJsonXRP branch — add null/type validation onvalueproperty to preventNullReferenceException - Fix
AccountId.FromJson— explicit null check to preventDecodeAccountID(null)crash - Fix
Uint64parsing — validate hex length after0xprefix to reject oversized inputs - Fix
AssetPriceConverter.Write— reject negativeint/longvalues instead of silentulongunderflow - 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 missingOracleHexStringConverteronProvider,AssetClass,URIproperties (matchingOracleSet) - Fix
XrplBinaryCodec.EncodeForSigningClaim— add null checks onchannelandamountproperties - Fix
SimulateRequest.Transaction— add explicitTransactionRequestConverterattribute for reliable polymorphic serialization - Fix
LedgerObjectConverter— extract sharedGetTypeForLedgerEntry()helper, eliminating duplicated 23-type switch - Fix
ScientificDecimalConverter— parse raw token text viadecimal.Parseinstead of lossydoublecast - Fix
EnumMemberValueConverter— remove permissiveEnum.TryParsefallback that accepted numeric strings
- Add
path_findWebSocket command —PathFind(create),PathFindClose,PathFindStatusmethods withPathFindCreateRequest,PathFindCloseRequest,PathFindStatusRequestmodels andPathFindResponse - Add
ripple_path_findcommand —RipplePathFindmethod withRipplePathFindRequest,RipplePathFindResponse,SourceCurrencymodels - Add
PathAlternativeshared model withPathsComputed,PathsCanonical,SourceAmount,DestinationAmount - Add
TypeandTypeHexbitmask fields toPathmodel for path step type identification - Fix
PathFindStream— changeDestinationAmount/SendMaxfromdecimaltoCurrency, changeIdfromGuid?toobject, replaceAlternativePathwith sharedPathAlternative - Fix message routing for
path_findasync follow-ups —RequestManager.HandleResponsenow returns(Response, Handled)tuple, unhandled messages withidare routed to stream processing - Add
TestEmitsPathFindunit test with two sequential stream messages validation - Add integration tests for
path_find(create/close/status/stream) andripple_path_find(basic/with source currencies) - Add
ParseMPTIDutility for MPTokenIssuanceID (XLS-33) encoding/decoding —GenerateMPTokenIssuanceID(sequence, issuer)andstring.ParseMPTokenIssuanceID()extension - Add
MPTokenIssuanceIdDatamodel mirroringNFTokenIdDatapattern (Sequence, Issuer, computed MPTokenIssuanceID) - Add computed
MPTokenIssuanceIDproperty toLOMPTokenIssuancederived fromSequence+Issuer - XLS-70 Credentials: full parity with
xrpl.js- Add
deposit_authorizedrequest/response models (DepositAuthorizedRequest,DepositAuthorized) with optional XLS-70credentialsparameter - Implement
IXrplClient.DepositAuthorized(request, ct)method - Add
CredentialIDs(Vector256, optional) field toPayment,EscrowFinish,AccountDelete,PaymentChannelClaimmodels, validation andTxFormat - Extend
DepositPreauthtransaction withAuthorizeCredentials/UnauthorizeCredentialsarrays and rewrite validation to enforce mutual exclusivity ofAuthorize/Unauthorize/AuthorizeCredentials/UnauthorizeCredentials - Fix broken
TxFormat[DepositPreauth](replaced PaymentChannelClaim fields with correct DepositPreauth fields including credential arrays) - Add shared
CredentialsValidator.ValidateCredentialsListhelper supporting both hex object IDs and wrapped{ Credential: { Issuer, CredentialType } }objects (max 8, hex format, no duplicates) - Fix binary codec: place
CredentialIDsatVector256 nth=5and moveHookNamespacestonth=32per rippled spec - Add
LedgerSpace.Credential = 'D'andHashes.HashCredential(subject, issuer, credentialType)helper to compute Credential ledger entry object IDs (SHA512Half) - Add unit tests for
CredentialsValidator, extendedDepositPreauthvalidation, andCredentialIDsvalidation across all four affected transactions - Add integration tests for
deposit_authorized(with/without credentials) and end-to-end XLS-70 scenario:CredentialCreate→CredentialAccept→AccountSet(asfDepositAuth)→DepositPreauth(AuthorizeCredentials)→Payment(CredentialIDs)
- Add
- Fix for Currency to HEX for currency with 1 or 2 symbol in name
- 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
- Fix
Currency.ValueAsNumbersetter precision — change format from"G15"to"G16"to preserve all 16 significant digits of XRPL token mantissa, preventingtecAMM_INVALID_TOKENSon full LP token withdrawal due to rounding up - Add unit tests for
Currencyclass — round-trip precision,ValueAsXrp, implicit operators,CurrencyExtensions, equality operators (39 tests)
- Add
deep_freezeanddeep_freeze_peerfields toTrustLinemodel (XLS-77 Deep Freeze support) - Add
Limitfield toAccountLinesresponse - Change
AccountLinesRequest.IgnoreDefaulttype frombooltobool? - Add
PseudoAccountfield toAccountInforesponse - Add
AMMIDfield toLOAccountRoot
- Fix
WaitForFinalTransactionOutcome—txnNotFoundwas never recognized due to reading emptyException.Datainstead ofRippledException.Response.Error, causing falseValidationExceptionon successful submissions - Replace generic
catch (Exception)inWaitForFinalTransactionOutcomewith split catch blocks:RippledExceptionwithwhenfilter fortxnNotFound, re-throw for other rippled errors,XrplExceptionwrapper for unexpected errors - Add null-safety for
ResponseinXrplErrorClassifier.Classify(RippledException)
- Add new ripple state flags support
- 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
autofilldefault fromfalsetotrue - Add
AllowTrustLineLockingflag to AccountInfoAccountFlags - Fix NoRippleCheck
Transactionsdeserialization — useList<ITransactionRequest>with polymorphicTransactionRequestConverter - Fix CurrencyConverter to handle
JsonToken.Integerfor XRP amounts
- 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
- Fix polymorphic ledger entry deserialization for
account_objects - Fix
ledger_dataJSON response mapping forstate - Add missing
ledger,validated, and ledger entry type filter support
- 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
- Fix ErrorResponse
- Fix RippledException when error in response
- 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
- MPToken Metadata parser
- 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
- Add Currency uint implicit conversion
- Fix API version set
- Connection stabilization improvements
- Minor config fix
- Documentation updates
- 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
- Fix race condition null exception in DID handling
- Add JSON writer for converters (DID fix)
- 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)
- Add signer locator (WalletLocator) encoding
- Update connection logic
- Fix encoding issues
- Documentation updates
- Add connection status tracking
- Fix namespace for BalanceChanges
- Add MPToken support (MPTokenAuthorize, MPTokenIssuanceCreate, MPTokenIssuanceDestroy, MPTokenIssuanceSet)
- Add currency extensions
- Add features request
- Signing refactoring — batch signing, in-batch multisign
- Refactor autofill logic
- Refactor TX common models
- Fix encoding and sign model issues
- Add sign batch tests
- Add Pbkdf2 for wallet from text
- 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
- Connection manager fix — auto-reconnect, connection ping-pong, reconnection progress
- Fix Payment deliverMax serialization
- Add deliverMax support
- Add warning notifications
- NFT parse update
- Add destination interface
- Fix ledger response
- Fix WebAssembly (WASM) support error
- Add Blazor test app
- Fix autofill fee calculation
- Add ledger entry types
- Fix serialization error
- 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
- Add XLS-46d (dynamic NFTs) transaction support
- Fix AMM Withdraw flags
- Fix client issues
- Fix NFTokenIds
- Fix Submit and wait logic
- Add TxV2 request/response
- 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
- Fix Trustlines JsonProperty and Limit default (thanks @ReneBrauwers)
- Add payment channel encoding
- Update XLS-20 fields
- Fix tests and initial setup
- Initial Release of XrplCSharp