Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
67da131
test: select the node under test from the environment
Platonenkov Sep 3, 2026
00fb0a1
fix(batch): refuse a Batch with a single inner transaction
Platonenkov Sep 3, 2026
b788263
test: coverage matrices for Batch inners, sponsored types and MPT AMM
Platonenkov Sep 3, 2026
3cf14bf
feat(loan): multisig counterparty co-signature for LoanSet
Platonenkov Sep 3, 2026
b63b62f
feat(xchain): witness-side signing of bridge attestations
Platonenkov Sep 3, 2026
90e67b9
chore(release): 11.3.0.0
Platonenkov Sep 3, 2026
e65e967
fix(wallet): FundWallet works more than once per process
Platonenkov Sep 3, 2026
9f04c2b
test: top an account up when one faucet payout is not enough
Platonenkov Sep 3, 2026
d3f261e
docs(changes): record the faucet fix
Platonenkov Sep 3, 2026
10ac64f
test: generate integration accounts instead of deriving them from a p…
Platonenkov Sep 3, 2026
7bdf39f
test(batch): poll for the inner transaction instead of asking once
Platonenkov Sep 3, 2026
0d82f1b
docs(changes): record the account and lookup fixes
Platonenkov Sep 3, 2026
bd84d80
test(batch): assert on the result the ledger recorded, not the engine…
Platonenkov Sep 3, 2026
97de08a
test(batch): clear the time gate instead of reaching it
Platonenkov Sep 3, 2026
58c6900
docs(changes): record the batch assertion and time-gate findings
Platonenkov Sep 3, 2026
5ac1755
fix(json): stop refusing Oracle values the ledger legitimately holds
Platonenkov Sep 3, 2026
6992695
test: make Utils.TestTransaction verify what it claimed to
Platonenkov Sep 3, 2026
bae1877
docs(changes): record the oracle and helper findings
Platonenkov Sep 3, 2026
45ea48d
test: make the suite portable to a clustered public endpoint
Platonenkov Sep 3, 2026
3546544
docs(changes): record the cluster and faucet findings
Platonenkov Sep 3, 2026
d4e5147
fix(wallet): keep the two-argument ComposeSignatures as an overload
Platonenkov Sep 3, 2026
3eb4f95
fix(json): a currency code whose padding is not padding is not text
Platonenkov Sep 3, 2026
eff9187
docs(xchain): say what attestation verification does not check
Platonenkov Sep 3, 2026
98fb020
test: review findings on running the suite against a public network
Platonenkov Sep 3, 2026
75cfa6f
docs(changes): record the review findings
Platonenkov Sep 3, 2026
a3e1cf0
test: bound the wait for a close time
Platonenkov Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .github/workflows/devnet-coverage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: Devnet Coverage

# Manual run of selected integration classes against a public network instead of the
# standalone stand. The point is amendment coverage: the XRPL Foundation dashboard
# (https://amendments-staging.xrpl.foundation) scores every amendment by the validated
# devnet transactions that exercised its transaction types, fields, flags and result
# codes, and the classes below are the ones that submit exactly that surface.
#
# Not part of CI on purpose: wallets are funded from the public faucet (rate-limited,
# ~100 XRP per call), the run depends on a third-party network, and a red result here
# says nothing about a pull request. Run it by hand when an amendment needs traffic or
# before a release to see the SDK against a real network.
#
# The test profile comes from the environment (Tests/Xrpl.Tests/Integration/Utils.cs):
# XRPL_TEST_NODE - testnet | devnet | standalone (funding and ledger_accept policy)
# XRPL_TEST_NODE_URL - WebSocket URL, overrides the built-in default for the profile
# Classes gated by AmendmentGuard skip themselves (Inconclusive) when the network does not
# have the amendment, so the filter can stay broad.

on:
workflow_dispatch:
inputs:
network:
description: Network profile (funding + ledger policy)
type: choice
options: [devnet, testnet]
default: devnet
node_url:
description: WebSocket URL (leave empty for the profile default)
type: string
default: ''
filter:
description: dotnet test --filter expression
type: string
default: >-
FullyQualifiedName~TestIBatchInnerTypes|FullyQualifiedName~TestISponsoredTypes|FullyQualifiedName~TestIAMMMpt|FullyQualifiedName~TestIXChainBridge|FullyQualifiedName~TestIBatch|FullyQualifiedName~TestISponsorship|FullyQualifiedName~TestILedgerStateFix

env:
DOTNET_VERSION: '10.0.x'

permissions:
contents: read

concurrency:
group: devnet-coverage
cancel-in-progress: false

jobs:
devnet:
runs-on: ubuntu-latest
timeout-minutes: 90

steps:
- uses: actions/checkout@v4

- name: Use .NET ${{ env.DOTNET_VERSION }}
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}

- name: Build
run: dotnet build Tests/Xrpl.Tests/Xrpl.Tests.csproj

- name: Run against ${{ inputs.network }}
env:
XRPL_TEST_NODE: ${{ inputs.network }}
XRPL_TEST_NODE_URL: ${{ inputs.node_url }}
run: >-
dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --no-build --verbosity normal
--settings test.runsettings
--filter "${{ inputs.filter }}"
--logger "trx;LogFileName=devnet-coverage.trx"
--results-directory TestResults

- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: devnet-coverage-${{ inputs.network }}-${{ github.run_id }}
path: TestResults/devnet-coverage.trx
if-no-files-found: ignore
6 changes: 3 additions & 3 deletions .github/workflows/dotnet.test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,11 @@ jobs:
run: dotnet restore
- name: Build
run: dotnet build
# The standalone profile is the default (XRPL_TEST_NODE unset -> ws://localhost:6006);
# a stand on other ports or a public network is selected with XRPL_TEST_NODE_URL /
# XRPL_TEST_NODE, see Tests/Xrpl.Tests/Integration/README.md and devnet-coverage.yml.
- name: Test Integration
run: dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --verbosity normal --settings test.runsettings --filter "TestI"
env:
HOST: localhost
PORT: 6006

# x402 integration tests: hermetic E2E against the standalone rippled above.
# The live t54 interop tests (TestCategory=Live) are excluded — they are the only tests
Expand Down
48 changes: 48 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,53 @@
# Changes

## 11.3.0.0 03/09/2026

* **`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 `await`ed 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

## 11.2.0.0 01/09/2026

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