Skip to content

v11.3.0

Choose a tag to compare

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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