Repository navigation
v11.3.0
-
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 successful faucet response carries the seed in
account.secret, and an exception message is the one thing a caller is certain to log. 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