From 67da131b15c5d0e8d8d5933ec011267fbcc9cd90 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 08:31:55 -0300 Subject: [PATCH 01/26] test: select the node under test from the environment Every TestI* class hard-coded TestNodeType.Standalone and the genesis account for funding, so XRPL_TEST_NODE selected nothing and the suite could only ever run against the local stand. The profile now comes from the environment: XRPL_TEST_NODE picks the funding policy and whether ledger_accept is issued, XRPL_TEST_NODE_URL overrides the WebSocket URL (a stand on other ports, a private node), and public networks fund wallets straight from the faucet with retries instead of through a 10 XRP filler. 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. TestILedgerStateFix submitted with fail_hard, which drops a tec result from the open ledger: its tecFAILED_PROCESSING never reached a validated ledger and proved nothing. It now goes in without the flag. devnet-coverage.yml (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. --- .github/workflows/devnet-coverage.yml | 81 ++++++++++++++++ .github/workflows/dotnet.test.yml | 6 +- CLAUDE.md | 3 + .../Xrpl.Tests/Integration/AmendmentGuard.cs | 19 ++++ Tests/Xrpl.Tests/Integration/README.md | 30 +++++- Tests/Xrpl.Tests/Integration/ServerUrl.cs | 8 +- .../Integration/TestIAdminCredentials.cs | 6 +- .../Integration/TestIConnectionStates.cs | 2 +- Tests/Xrpl.Tests/Integration/Utils.cs | 92 ++++++++----------- .../requests/TestINFTClioCommands.cs | 2 +- .../Integration/requests/TestIPathPayment.cs | 2 +- .../Integration/requests/gatewayBalances.cs | 2 +- .../Integration/transactions/TestIAMMBase.cs | 12 ++- .../transactions/TestIAMMClawback.cs | 12 ++- .../transactions/TestIBatchSponsorship.cs | 2 +- .../Integration/transactions/TestIClawback.cs | 2 +- .../transactions/TestIConfidentialMPT.cs | 2 +- .../transactions/TestICredential.cs | 2 +- .../Integration/transactions/TestIDID.cs | 2 +- .../transactions/TestIDelegateSet.cs | 4 +- .../transactions/TestIDomainAccess.cs | 2 +- .../Integration/transactions/TestIEscrow.cs | 2 +- .../transactions/TestILedgerStateFix.cs | 38 +++++--- .../Integration/transactions/TestILoanBase.cs | 4 +- .../transactions/TestIMPTokenBase.cs | 16 +++- .../transactions/TestIMemoLimits.cs | 2 +- .../Integration/transactions/TestIOracle.cs | 2 +- .../transactions/TestIPermissionedDomain.cs | 2 +- .../transactions/TestISponsorship.cs | 4 +- .../TestISponsorshipSigningMatrix.cs | 2 +- .../transactions/TestITypedSubmitFailure.cs | 2 +- .../transactions/TestIVaultBase.cs | 4 +- .../transactions/TestIXChainBridge.cs | 12 +++ .../transactions/TestIXChainBridgeBase.cs | 4 +- 34 files changed, 281 insertions(+), 106 deletions(-) create mode 100644 .github/workflows/devnet-coverage.yml diff --git a/.github/workflows/devnet-coverage.yml b/.github/workflows/devnet-coverage.yml new file mode 100644 index 00000000..45c766c3 --- /dev/null +++ b/.github/workflows/devnet-coverage.yml @@ -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 diff --git a/.github/workflows/dotnet.test.yml b/.github/workflows/dotnet.test.yml index 9c75f70c..ad88bfa9 100644 --- a/.github/workflows/dotnet.test.yml +++ b/.github/workflows/dotnet.test.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index d3b0b39c..b6c6792d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -172,6 +172,8 @@ dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --fil docker compose -f .ci-config/docker-compose.batchv11.yml down ``` +The node under test is selected through the environment, not in code: `XRPL_TEST_NODE` (`standalone` default, `devnet`, `testnet`) picks the funding policy, `XRPL_TEST_NODE_URL` overrides the WebSocket URL (a stand on other ports, a private node). See `Tests/Xrpl.Tests/Integration/README.md`. + Amendment-dependent test classes use `Tests/Xrpl.Tests/Integration/AmendmentGuard.cs`: `ClassInitialize` checks the Amendments ledger object and marks tests inconclusive (skipped, exit 0) when the amendment is not active — so these tests are safe on the CI stand and run for real on the nightly stand. To gate a new test class, add the amendment id constant to `AmendmentGuard` and call `Assert.Inconclusive` from `TestInitialize` when inactive. Nightly stand specifics (see comments in `Dockerfile.nightly` / `rippled.batchv11.cfg`): @@ -199,6 +201,7 @@ Output goes to `docs/` directory. Published to GitHub Pages. | `protocol-watch.yml` | Weekly cron (Mon 06:00 UTC); manual | Diffs rippled develop `*.macro` protocol files vs the baseline in the `protocol-watch` tracking issue; comments there on changes | | `release-watch.yml` | Weekly cron (Mon 06:30 UTC); manual | Checks the CI stand against the latest stable rippled release; when a newer release has a Docker image, regenerates the stand config (`generate-amendments.sh`), smoke-tests it and opens a bump PR (via GitHub App `RELEASE_WATCH_APP_ID`/`RELEASE_WATCH_APP_PRIVATE_KEY` or `RELEASE_WATCH_PAT`; falls back to a `release-watch` issue comment) | | `definitions-watch.yml` | Weekly cron (Mon 07:00 UTC); manual | Raises the nightly stand and diffs `definitions.json` against its `server_definitions`; red when the node has fields the SDK lacks. Its horizon is the nightly pin, so a stale pin makes it report on the past | +| `devnet-coverage.yml` | Manual only | Runs the coverage-oriented integration classes (`TestIBatchInnerTypes`, `TestISponsoredTypes`, `TestIAMMMpt`, XChain, Sponsorship, Batch) against devnet or testnet with faucet funding; feeds the XRPL Foundation amendment dashboard. Never part of CI | | `nightly-pin-watch.yml` | Weekly cron (Mon 05:00 UTC); manual | Checks the nightly `xrpld` pin against the nightly apt channel; once it is older than `MAX_PIN_AGE_DAYS` (21), runs `bump-nightly-pin.sh`, starts the stand on the new pin, and opens a bump PR (same credential ladder as release-watch; falls back to a `nightly-pin-watch` issue comment) | Integration tests do **not** run on PRs into `dev` — `dev` uses a GitHub merge queue, and the `integration` job runs on the `merge_group` event against the merge result before the merge lands ("Merge when ready" button). Promotion PRs into `release` still run the full suite directly. New pushes to a PR cancel its in-flight CI run (`concurrency`, PR events only). diff --git a/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs b/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs index 07a8a44b..f8601156 100644 --- a/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs +++ b/Tests/Xrpl.Tests/Integration/AmendmentGuard.cs @@ -38,6 +38,25 @@ public static class AmendmentGuard /// Amendment id of PermissionedDomains / XLS-80 (sha512half of the name). public const string PermissionedDomains = "A730EB18A9D4BB52502C898589558B4CCEB4BE10044500EE5581137A2E80E849"; + /// Amendment id of AMM / XLS-30 (sha512half of the name). + public const string AMM = "8CC0774A3BF66D1D22E76BBDA8E8A232E6B6313834301B3B23E8601196AE6455"; + + /// Amendment id of AMMClawback / XLS-73 (sha512half of the name). + public const string AMMClawback = "726F944886BCDF7433203787E93DD9AA87FAB74DFE3AF4785BA03BEFC97ADA1F"; + + /// Amendment id of MPTokensV1 / XLS-33 (sha512half of the name). + public const string MPTokensV1 = "950AE2EA4654E47F04AA8739C0B214E242097E802FD372D24047A89AB1F5EC38"; + + /// + /// Amendment id of MPTokensV2 / XLS-62 (sha512half of the name). On the standalone + /// stands it is a [features] Rules preset, not an on-ledger amendment, so the guard + /// reports it disabled there even though MPT-in-AMM transactors work. + /// + public const string MPTokensV2 = "BE2D87DF21B690ED1497B593FDC013CC04276302380B1BD50A033DCF8DEFB2EB"; + + /// Amendment id of XChainBridge / XLS-38 (sha512half of the name). + public const string XChainBridge = "C98D98EE9616ACD36E81FDEB8D41D349BF5F1B41DD64A0ABC1FE9AA5EA267E9C"; + public static async Task IsEnabledAsync(IXrplClient client, string amendmentId) { try diff --git a/Tests/Xrpl.Tests/Integration/README.md b/Tests/Xrpl.Tests/Integration/README.md index a0486acb..2001ef68 100644 --- a/Tests/Xrpl.Tests/Integration/README.md +++ b/Tests/Xrpl.Tests/Integration/README.md @@ -21,6 +21,34 @@ dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --fil docker compose -f .ci-config/docker-compose.batchv11.yml down ``` -Tests connect to `ws://localhost:6006` (see `ServerUrl.cs`) and fund wallets from the standalone genesis account (see `Utils.cs`). +Tests connect to `ws://localhost:6006` by default and fund wallets from the standalone genesis account (see `Utils.cs`). + +## Node profile + +The node under test is chosen through the environment (`IntegrationTestConfig` in `Utils.cs`); every `TestI*` class reads it, none hard-codes a node: + +| Variable | Values | Effect | +|----------|--------|--------| +| `XRPL_TEST_NODE` | `standalone` (default), `devnet`, `testnet` | Funding policy (genesis account vs. public faucet) and whether `ledger_accept` is issued | +| `XRPL_TEST_NODE_URL` | any WebSocket URL | Replaces the profile's default URL - a stand on other ports, or a private node | +| `XRPL_TEST_ADMIN_AUTH_URL` | any WebSocket URL | The credential-protected admin port (`[port_ws_admin_auth]`, default `ws://127.0.0.1:6007`) used by `TestIAdminCredentials`; standalone only | + +```bash +# a second standalone stand published on other ports +XRPL_TEST_NODE_URL=ws://localhost:7016 dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestI" + +# devnet: wallets come from the public faucet (about 100 XRP each, rate-limited) +XRPL_TEST_NODE=devnet dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "FullyQualifiedName~TestIBatchInnerTypes|FullyQualifiedName~TestISponsoredTypes" +``` + +Amendment-gated classes check the network through `AmendmentGuard` and mark themselves inconclusive when the amendment is not active there, so a broad filter is safe on any profile. The GitHub workflow `devnet-coverage.yml` (manual dispatch) runs the coverage-oriented classes against devnet this way. + +## Coverage matrices + +Three classes exist to exercise the SDK's transaction surface end to end rather than one feature at a time. They double as traffic for the XRPL Foundation amendment dashboard, which scores devnet by the transaction types, fields and flags that validated transactions have touched: + +- `TestIBatchInnerTypes` - every transaction type wrapped as a Batch inner (XLS-56), each inner read back by its computed id. rippled rejects a Batch with fewer than two inners (`temARRAY_EMPTY`), which the SDK validation mirrors. +- `TestISponsoredTypes` - the `Sponsor` field (XLS-68) on every transaction type other than Payment, with the sponsor co-signing. +- `TestIAMMMpt` - every AMM transaction type over an MPT asset (XLS-62, `MPTokensV2`). On the standalone stands the amendment is a `[features]` preset invisible to the on-ledger guard, so the class runs there unconditionally. Full details — amendment activation, `temDISABLED` troubleshooting, genesis funding, ledger advancement: [Standalone Node Guide](../../../DocFx/StandaloneNode-Guide.md) ([русская версия](../../../DocFx/StandaloneNode-Guide.ru.md)). diff --git a/Tests/Xrpl.Tests/Integration/ServerUrl.cs b/Tests/Xrpl.Tests/Integration/ServerUrl.cs index d44010fd..e278d34c 100644 --- a/Tests/Xrpl.Tests/Integration/ServerUrl.cs +++ b/Tests/Xrpl.Tests/Integration/ServerUrl.cs @@ -2,8 +2,10 @@ { public class ServerUrl { - static string HOST = "localhost"; - static string PORT = "6006"; - public static string serverUrl = $"ws://{HOST}:{PORT}"; + /// + /// WebSocket URL of the node under test, resolved from the active + /// profile (XRPL_TEST_NODE / XRPL_TEST_NODE_URL). + /// + public static string serverUrl => IntegrationTestConfig.GetNodeUrl(IntegrationTestConfig.CurrentNodeType); } } \ No newline at end of file diff --git a/Tests/Xrpl.Tests/Integration/TestIAdminCredentials.cs b/Tests/Xrpl.Tests/Integration/TestIAdminCredentials.cs index 3f71cd4f..66c3eb43 100644 --- a/Tests/Xrpl.Tests/Integration/TestIAdminCredentials.cs +++ b/Tests/Xrpl.Tests/Integration/TestIAdminCredentials.cs @@ -15,7 +15,8 @@ namespace Xrpl.Tests.Integration /// Verifies that / /// actually unlock rippled admin commands over WebSocket. /// - /// Runs against [port_ws_admin_auth] of the standalone stand (port 6007), which sets + /// Runs against [port_ws_admin_auth] of the standalone stand (port 6007, or the URL in + /// XRPL_TEST_ADMIN_AUTH_URL for a stand published on other ports), which sets /// admin_user/admin_password. rippled carries these credentials in the request JSON, /// not in an HTTP header — Basic auth on the ws handshake is never checked by the node itself. /// @@ -25,7 +26,8 @@ public class TestIAdminCredentials { private const string AdminUser = "xrpl_admin"; private const string AdminPassword = "xrpl_admin_secret"; - private const string ServerUrl = "ws://127.0.0.1:6007"; + private static readonly string ServerUrl = + Environment.GetEnvironmentVariable("XRPL_TEST_ADMIN_AUTH_URL") is { Length: > 0 } url ? url.Trim() : "ws://127.0.0.1:6007"; private static readonly Dictionary LedgerAccept = new() { diff --git a/Tests/Xrpl.Tests/Integration/TestIConnectionStates.cs b/Tests/Xrpl.Tests/Integration/TestIConnectionStates.cs index c7e0cdee..343af6e4 100644 --- a/Tests/Xrpl.Tests/Integration/TestIConnectionStates.cs +++ b/Tests/Xrpl.Tests/Integration/TestIConnectionStates.cs @@ -30,7 +30,7 @@ namespace Xrpl.Tests.Integration; [TestCategory("TestI")] public class TestIConnectionStates { - private static string LocalServer => IntegrationTestConfig.GetNodeUrl(TestNodeType.Standalone); + private static string LocalServer => IntegrationTestConfig.GetNodeUrl(IntegrationTestConfig.CurrentNodeType); /// /// The same node under a different URL spelling — enough to exercise a real server switch diff --git a/Tests/Xrpl.Tests/Integration/Utils.cs b/Tests/Xrpl.Tests/Integration/Utils.cs index d59b4a02..97b1f155 100644 --- a/Tests/Xrpl.Tests/Integration/Utils.cs +++ b/Tests/Xrpl.Tests/Integration/Utils.cs @@ -84,13 +84,24 @@ public static class IntegrationTestConfig /// public const decimal MinBalanceThreshold = 50m; + /// + /// Environment variable that replaces the WebSocket URL for any node type, + /// e.g. a standalone stand on non-default ports or a private devnet node. + /// + public const string NodeUrlEnvironmentVariable = "XRPL_TEST_NODE_URL"; + /// /// Gets the WebSocket URL for the specified node type. + /// , when set, wins over the built-in defaults. /// /// The type of node to connect to. /// WebSocket URL string. public static string GetNodeUrl(TestNodeType nodeType) { + string overrideUrl = Environment.GetEnvironmentVariable(NodeUrlEnvironmentVariable); + if (!string.IsNullOrWhiteSpace(overrideUrl)) + return overrideUrl.Trim(); + return nodeType switch { TestNodeType.TestNet => "wss://s.altnet.rippletest.net:51233", @@ -103,7 +114,7 @@ public static string GetNodeUrl(TestNodeType nodeType) /// /// Gets the node type from the XRPL_TEST_NODE environment variable. - /// Defaults to TestNet if not set or invalid. + /// Defaults to Standalone when the variable is not set. /// private static TestNodeType GetNodeTypeFromEnvironment() { @@ -164,13 +175,7 @@ public static async Task CreateClientAsync(TestNodeType? nodeType = /// Optional node type override. public static async Task FundWalletAsync(IXrplClient client, XrplWallet wallet, TestNodeType? nodeType = null) { - var type = nodeType ?? client.Url() switch - { - { } url when url.Contains("altnet") => TestNodeType.TestNet, - { } url when url.Contains("devnet") => TestNodeType.DevNet, - { } url when url.Contains("localhost") => TestNodeType.Standalone, - _ => TestNodeType.MainNet, - }; + TestNodeType type = nodeType ?? CurrentNodeType; if (type == TestNodeType.Standalone) { @@ -226,34 +231,35 @@ public static async Task TryFundWalletsAsync(IXrplClient client, TestNodeType? n } } - private static XrplWallet FaucetFiller = null; + /// + /// Number of faucet calls attempted for one wallet before giving up. + /// + private const int FaucetAttempts = 3; /// - /// Funds a wallet from the testnet/devnet faucet. - /// Serialized via StandaloneLock to prevent FaucetFiller sequence conflicts. + /// Funds a wallet straight from the testnet/devnet faucet. + /// Calls are serialized via StandaloneLock so parallel test classes do not + /// hammer the faucet rate limit; each failed call is retried with a growing delay. /// private static async Task FundFromFaucetAsync(IXrplClient client, XrplWallet wallet) { await StandaloneLock.FaucetFunding.WaitAsync(); try { - if (FaucetFiller is null) - { - FaucetFiller = XrplWallet.Generate(); - Console.WriteLine($"[IntegrationTest] FaucetFiller generated {FaucetFiller.ClassicAddress}"); - var result = await client.FundWallet(FaucetFiller); - Console.WriteLine($"[IntegrationTest] FaucetFiller funded {FaucetFiller.ClassicAddress}: {result.Balance} XRP"); - } - - if (await client.GetXrpFreeBalance(FaucetFiller.ClassicAddress) is { } balance and > 50) + for (int attempt = 1; ; attempt++) { - await FundFromFaucetFillerAsync(client, wallet, 10); - } - else - { - var result = await client.FundWallet(FaucetFiller); - Console.WriteLine($"[IntegrationTest] FaucetFiller funded {FaucetFiller.ClassicAddress}: {result.Balance} XRP"); - await FundFromFaucetFillerAsync(client, wallet, 10); + try + { + await client.FundWallet(wallet); + decimal balance = await client.GetXrpFreeBalance(wallet.ClassicAddress); + Console.WriteLine($"[IntegrationTest] Faucet funded {wallet.ClassicAddress}: {balance} XRP"); + return; + } + catch (Exception ex) when (attempt < FaucetAttempts) + { + Console.WriteLine($"[IntegrationTest] Faucet attempt {attempt}/{FaucetAttempts} for {wallet.ClassicAddress} failed: {ex.Message}"); + await Task.Delay(TimeSpan.FromSeconds(5 * attempt)); + } } } finally @@ -262,29 +268,6 @@ private static async Task FundFromFaucetAsync(IXrplClient client, XrplWallet wal } } - /// - /// Funds a wallet from the faucetFiller. - /// - private static async Task FundFromFaucetFillerAsync(IXrplClient client, XrplWallet wallet, decimal xrpSize) - { - Payment payment = new Payment - { - Account = FaucetFiller.ClassicAddress, - Destination = wallet.ClassicAddress, - Amount = new Currency { ValueAsXrp = xrpSize, CurrencyCode = "XRP" } - }; - - var values = JsonSerializer.Deserialize>(payment.ToJson(), global::Xrpl.Client.Json.XrplJsonOptions.Default); - var response = await client.SubmitAndWait(values, FaucetFiller, autofill: true); - - if (response.Meta.TransactionResult != "tesSUCCESS") - { - throw new Exception($"Filler funding failed: {response.Meta.TransactionResult}"); - } - - Console.WriteLine($"[IntegrationTest] Filler funded {wallet.ClassicAddress}"); - } - /// /// Funds a wallet from the standalone master account. /// Uses Submit + LedgerAccept instead of SubmitAndWait to avoid @@ -377,12 +360,17 @@ public class Utils public static async Task LedgerAccept(IXrplClient client) { - var request = new BaseRequest { Command = "ledger_accept" }; - await client.AnyRequest(request); + await IntegrationTestConfig.LedgerAcceptAsync(client); } public static async Task FundAccount(IXrplClient client, XrplWallet wallet) { + if (!IntegrationTestConfig.IsStandalone()) + { + await IntegrationTestConfig.FundWalletAsync(client, wallet); + return; + } + await StandaloneLock.MasterFunding.WaitAsync(); try { diff --git a/Tests/Xrpl.Tests/Integration/requests/TestINFTClioCommands.cs b/Tests/Xrpl.Tests/Integration/requests/TestINFTClioCommands.cs index 01399ae0..cf1914e1 100644 --- a/Tests/Xrpl.Tests/Integration/requests/TestINFTClioCommands.cs +++ b/Tests/Xrpl.Tests/Integration/requests/TestINFTClioCommands.cs @@ -35,7 +35,7 @@ public class TestINFTClioCommands [ClassInitialize] public static async Task ClassInitializeAsync(TestContext testContext) { - client = await IntegrationTestConfig.CreateClientAsync(TestNodeType.Standalone); + client = await IntegrationTestConfig.CreateClientAsync(); } [ClassCleanup] diff --git a/Tests/Xrpl.Tests/Integration/requests/TestIPathPayment.cs b/Tests/Xrpl.Tests/Integration/requests/TestIPathPayment.cs index cebced82..649b9720 100644 --- a/Tests/Xrpl.Tests/Integration/requests/TestIPathPayment.cs +++ b/Tests/Xrpl.Tests/Integration/requests/TestIPathPayment.cs @@ -18,7 +18,7 @@ namespace XrplTests.Xrpl.ClientLib.Integration public class TestIPathPayment { public TestContext TestContext { get; set; } - public static TestNodeType nodeType = TestNodeType.Standalone; + public static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; const string CurrencyCode = "PPT"; diff --git a/Tests/Xrpl.Tests/Integration/requests/gatewayBalances.cs b/Tests/Xrpl.Tests/Integration/requests/gatewayBalances.cs index 6425fd4f..b2843d44 100644 --- a/Tests/Xrpl.Tests/Integration/requests/gatewayBalances.cs +++ b/Tests/Xrpl.Tests/Integration/requests/gatewayBalances.cs @@ -22,7 +22,7 @@ public class TestIGatewayBalances public TestContext TestContext { get; set; } static IXrplClient client; - private static TestNodeType nodeType = TestNodeType.Standalone; + private static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; [ClassInitialize] public static async Task MyClassInitializeAsync(TestContext testContext) diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIAMMBase.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIAMMBase.cs index 945f23f5..86cd44c4 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIAMMBase.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIAMMBase.cs @@ -27,11 +27,19 @@ public abstract class TestIAMMBase protected XrplWallet walletIssuer; protected XrplWallet walletHolder; protected const string CurrencyCode = "AML"; - protected static TestNodeType nodeType = TestNodeType.Standalone; + protected static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; + + private static bool? _ammEnabled; [TestInitialize] public async Task TestInitialize() { + _ammEnabled ??= await AmendmentGuard.IsEnabledAsync(GetClient(), AmendmentGuard.AMM); + if (!_ammEnabled.Value) + { + Assert.Inconclusive("AMM amendment is not enabled on the test node."); + } + walletIssuer = XrplWallet.Generate(); walletHolder = XrplWallet.Generate(); @@ -211,6 +219,6 @@ protected async Task DepositSecondHolder(XrplWallet secondHolder, decimal lpFrac protected static async Task CreateStandaloneClient() { - return await IntegrationTestConfig.CreateClientAsync(TestNodeType.Standalone); + return await IntegrationTestConfig.CreateClientAsync(); } } diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIAMMClawback.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIAMMClawback.cs index e53afe0b..94c517d4 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIAMMClawback.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIAMMClawback.cs @@ -35,12 +35,12 @@ public class TestIAMMClawback private XrplWallet walletHolder; const string CurrencyCode = "AMC"; - public static TestNodeType nodeType = TestNodeType.Standalone; + public static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; [ClassInitialize] public static async Task ClassInitializeAsync(TestContext testContext) { - client = await IntegrationTestConfig.CreateClientAsync(TestNodeType.Standalone); + client = await IntegrationTestConfig.CreateClientAsync(); } [ClassCleanup] @@ -49,9 +49,17 @@ public static void ClassCleanup() client?.Dispose(); } + private static bool? _ammClawbackEnabled; + [TestInitialize] public async Task TestInitialize() { + _ammClawbackEnabled ??= await AmendmentGuard.IsEnabledAsync(client, AmendmentGuard.AMMClawback); + if (!_ammClawbackEnabled.Value) + { + Assert.Inconclusive("AMMClawback amendment is not enabled on the test node."); + } + walletIssuer = XrplWallet.Generate(); walletHolder = XrplWallet.Generate(); diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs index 794ee3f7..6a43903e 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIBatchSponsorship.cs @@ -34,7 +34,7 @@ public class TestIBatchSponsorship public TestContext TestContext { get; set; } private static IXrplClient client; - private static TestNodeType nodeType = TestNodeType.Standalone; + private static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; [ClassInitialize] public static async Task ClassInitializeAsync(TestContext testContext) diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIClawback.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIClawback.cs index 4e95ea1f..320b2655 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIClawback.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIClawback.cs @@ -36,7 +36,7 @@ public class TestIClawback const string CurrencyCode = "CLW"; static bool issuerInitialized = false; static bool holderInitialized = false; - public static TestNodeType nodeType = TestNodeType.Standalone; + public static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; [ClassInitialize] public static async Task MyClassInitializeAsync(TestContext testContext) diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIConfidentialMPT.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIConfidentialMPT.cs index c33014db..813d10a7 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIConfidentialMPT.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIConfidentialMPT.cs @@ -28,7 +28,7 @@ public class TestIConfidentialMPT public TestContext TestContext { get; set; } private static IXrplClient client; - private static TestNodeType nodeType = TestNodeType.Standalone; + private static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; [ClassInitialize] public static async Task ClassInitializeAsync(TestContext testContext) diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestICredential.cs b/Tests/Xrpl.Tests/Integration/transactions/TestICredential.cs index 42cdae5f..8cc755bd 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestICredential.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestICredential.cs @@ -29,7 +29,7 @@ public class TestICredential public TestContext TestContext { get; set; } public static IXrplClient client; - public static TestNodeType nodeType = TestNodeType.Standalone; + public static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; [ClassInitialize] public static async Task MyClassInitializeAsync(TestContext testContext) diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIDID.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIDID.cs index cebf54d1..0f7df3ce 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIDID.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIDID.cs @@ -24,7 +24,7 @@ public class TestIDID public TestContext TestContext { get; set; } public static IXrplClient client; - public static TestNodeType nodeType = TestNodeType.Standalone; + public static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; [ClassInitialize] public static async Task MyClassInitializeAsync(TestContext testContext) { diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIDelegateSet.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIDelegateSet.cs index 626f32ca..0b0d1841 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIDelegateSet.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIDelegateSet.cs @@ -24,12 +24,12 @@ public class TestIDelegateSet public TestContext TestContext { get; set; } private static IXrplClient client; - private static TestNodeType nodeType = TestNodeType.Standalone; + private static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; [ClassInitialize] public static async Task ClassInitializeAsync(TestContext testContext) { - client = await IntegrationTestConfig.CreateClientAsync(TestNodeType.Standalone); + client = await IntegrationTestConfig.CreateClientAsync(); permissionDelegationActive = await AmendmentGuard.IsEnabledAsync(client, AmendmentGuard.PermissionDelegationV11); } diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIDomainAccess.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIDomainAccess.cs index 77f7fe65..50dae8d7 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIDomainAccess.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIDomainAccess.cs @@ -19,7 +19,7 @@ public class TestIDomainAccess { public TestContext TestContext { get; set; } public static IXrplClient client; - public static TestNodeType nodeType = TestNodeType.Standalone; + public static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; private static bool permissionedDomainsActive; [ClassInitialize] diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIEscrow.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIEscrow.cs index 9428ec73..36d9f2f6 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIEscrow.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIEscrow.cs @@ -19,7 +19,7 @@ public class TestIEscrow public TestContext TestContext { get; set; } public static IXrplClient client; - private static TestNodeType nodeType = TestNodeType.Standalone; + private static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; /// /// How far ahead of the last validated close time an escrow's FinishAfter is placed. diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestILedgerStateFix.cs b/Tests/Xrpl.Tests/Integration/transactions/TestILedgerStateFix.cs index da0fd046..1b522b35 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestILedgerStateFix.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestILedgerStateFix.cs @@ -3,6 +3,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Xrpl.Client; +using Xrpl.Client.Exceptions; using Xrpl.Models.Methods; using Xrpl.Models.Transactions; using Xrpl.Sugar; @@ -12,24 +13,31 @@ namespace XrplTests.Xrpl.ClientLib.Integration; [TestClass] [TestCategory("LedgerStateFix")] -//[Ignore("LedgerStateFix requires the LedgerStateFix amendment which may not be available on standalone")] public class TestILedgerStateFix { public TestContext TestContext { get; set; } private static IXrplClient client; - private static TestNodeType nodeType = TestNodeType.Standalone; + private static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; [ClassInitialize] public static async Task ClassInitializeAsync(TestContext testContext) { - client = await IntegrationTestConfig.CreateClientAsync(TestNodeType.Standalone); + client = await IntegrationTestConfig.CreateClientAsync(); } [ClassCleanup] public static void ClassCleanup() => client?.Dispose(); + /// + /// LedgerStateFix only repairs real corruption (a broken NFT page chain), so on a + /// healthy account rippled answers tecFAILED_PROCESSING. The transaction is submitted + /// WITHOUT fail_hard on purpose: with it a tec result is dropped from the open ledger + /// and never validated, so nothing would prove the node accepted the transaction at all. + /// Without it the tec result is applied, the fee is claimed and the transaction reaches + /// a validated ledger like any other. + /// [TestMethod] - public async Task TestLedgerStateFix_Basic() + public async Task TestLedgerStateFix_Basic_ReachesValidatedLedger() { XrplWallet wallet = XrplWallet.Generate(); await IntegrationTestConfig.TryFundWalletAsync(client, wallet, nodeType); @@ -40,18 +48,22 @@ public async Task TestLedgerStateFix_Basic() LedgerFixType = 1, Owner = wallet.ClassicAddress, }; - // Autofill automatically sets the reserve fee for LedgerStateFix (>= owner reserve, 0.2 XRP) + // Autofill sets the reserve-level fee LedgerStateFix requires (>= owner reserve) tx = await client.Autofill(tx); - // Use fail_hard to avoid paying the high fee if the transaction would fail - var result = await client.Submit(tx, wallet, true, true); + string result; + try + { + TransactionSummary summary = await client.SubmitAndWait(tx, wallet, autofill: false); + result = summary.Meta?.TransactionResult; + } + catch (TransactionFailedException ex) + { + result = ex.Message; + } - // tecFAILED_PROCESSING is expected on a healthy account — LedgerStateFix only - // succeeds when there is an actual ledger corruption to repair (e.g. broken NFT directory). - // On a fresh wallet with no issues, the network correctly rejects the fix attempt. - string txResult = result.EngineResult; Assert.IsTrue( - txResult is "tesSUCCESS" or "tecFAILED_PROCESSING", - $"Expected tesSUCCESS or tecFAILED_PROCESSING, got: {txResult}"); + result is not null && (result.Contains("tesSUCCESS") || result.Contains("tecFAILED_PROCESSING")), + $"Expected tesSUCCESS or tecFAILED_PROCESSING, got: {result}"); } } diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs b/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs index c4cd47f8..5e2cca4a 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs @@ -24,7 +24,7 @@ public abstract class TestILoanBase { public TestContext TestContext { get; set; } protected abstract IXrplClient GetClient(); - protected static TestNodeType nodeType = TestNodeType.Standalone; + protected static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; protected static void ValidateResult(Submit res) { @@ -374,6 +374,6 @@ protected static async Task SubmitSignedLoanSet(IXrplClient protected static async Task CreateStandaloneClient() { - return await IntegrationTestConfig.CreateClientAsync(TestNodeType.Standalone); + return await IntegrationTestConfig.CreateClientAsync(); } } diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIMPTokenBase.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIMPTokenBase.cs index f24ea51b..f0ab3c0a 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIMPTokenBase.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIMPTokenBase.cs @@ -16,7 +16,19 @@ public abstract class TestIMPTokenBase { public TestContext TestContext { get; set; } protected abstract IXrplClient GetClient(); - protected static TestNodeType nodeType = TestNodeType.Standalone; + protected static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; + + private static bool? _mptokensV1Enabled; + + [TestInitialize] + public async Task CheckMPTokensV1Amendment() + { + _mptokensV1Enabled ??= await AmendmentGuard.IsEnabledAsync(GetClient(), AmendmentGuard.MPTokensV1); + if (!_mptokensV1Enabled.Value) + { + Assert.Inconclusive("MPTokensV1 amendment is not enabled on the test node."); + } + } protected static void ValidateResult(Submit res) { @@ -56,6 +68,6 @@ protected static string GetMPTokenIssuanceIdFromMeta(TransactionSummary result) protected static async Task CreateStandaloneClient() { - return await IntegrationTestConfig.CreateClientAsync(TestNodeType.Standalone); + return await IntegrationTestConfig.CreateClientAsync(); } } diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIMemoLimits.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIMemoLimits.cs index 2abf3b9d..bb0cf834 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIMemoLimits.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIMemoLimits.cs @@ -42,7 +42,7 @@ public class TestIMemoLimits [ClassInitialize] public static async Task ClassInitializeAsync(TestContext testContext) { - client = await IntegrationTestConfig.CreateClientAsync(TestNodeType.Standalone); + client = await IntegrationTestConfig.CreateClientAsync(); wallet = await Utils.GenerateFundedWallet(client); } diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIOracle.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIOracle.cs index 010f9f88..db9e9821 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIOracle.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIOracle.cs @@ -32,7 +32,7 @@ public class TestIOracle public TestContext TestContext { get; set; } public static IXrplClient client; - public static TestNodeType nodeType = TestNodeType.Standalone; + public static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; private static bool priceOracleAmendmentActive; diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIPermissionedDomain.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIPermissionedDomain.cs index 93c93c09..0228e765 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIPermissionedDomain.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIPermissionedDomain.cs @@ -21,7 +21,7 @@ public class TestIPermissionedDomain { public TestContext TestContext { get; set; } public static IXrplClient client; - public static TestNodeType nodeType = TestNodeType.Standalone; + public static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; [ClassInitialize] public static async Task MyClassInitializeAsync(TestContext testContext) diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs b/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs index 46e83f98..6451a7b8 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs @@ -23,12 +23,12 @@ public class TestISponsorship public TestContext TestContext { get; set; } private static IXrplClient client; - private static TestNodeType nodeType = TestNodeType.Standalone; + private static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; [ClassInitialize] public static async Task ClassInitializeAsync(TestContext testContext) { - client = await IntegrationTestConfig.CreateClientAsync(TestNodeType.Standalone); + client = await IntegrationTestConfig.CreateClientAsync(); sponsorAmendmentActive = await AmendmentGuard.IsEnabledAsync(client, AmendmentGuard.Sponsor); } diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestISponsorshipSigningMatrix.cs b/Tests/Xrpl.Tests/Integration/transactions/TestISponsorshipSigningMatrix.cs index 54127acc..50e01cee 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestISponsorshipSigningMatrix.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestISponsorshipSigningMatrix.cs @@ -34,7 +34,7 @@ public class TestISponsorshipSigningMatrix public TestContext TestContext { get; set; } private static IXrplClient client; - private static TestNodeType nodeType = TestNodeType.Standalone; + private static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; [ClassInitialize] public static async Task ClassInitializeAsync(TestContext testContext) diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestITypedSubmitFailure.cs b/Tests/Xrpl.Tests/Integration/transactions/TestITypedSubmitFailure.cs index 9573c699..09b76259 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestITypedSubmitFailure.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestITypedSubmitFailure.cs @@ -28,7 +28,7 @@ public class TestITypedSubmitFailure [ClassInitialize] public static async Task ClassInitializeAsync(TestContext testContext) { - client = await IntegrationTestConfig.CreateClientAsync(TestNodeType.Standalone); + client = await IntegrationTestConfig.CreateClientAsync(); wallet = await Utils.GenerateFundedWallet(client); } diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIVaultBase.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIVaultBase.cs index 21e9d1be..563be313 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIVaultBase.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIVaultBase.cs @@ -13,7 +13,7 @@ public abstract class TestIVaultBase { public TestContext TestContext { get; set; } protected abstract IXrplClient GetClient(); - protected static TestNodeType nodeType = TestNodeType.Standalone; + protected static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; protected static void ValidateResult(Submit res) { @@ -29,6 +29,6 @@ protected static void ValidateResult(TransactionSummary res) protected static async Task CreateStandaloneClient() { - return await IntegrationTestConfig.CreateClientAsync(TestNodeType.Standalone); + return await IntegrationTestConfig.CreateClientAsync(); } } diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIXChainBridge.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIXChainBridge.cs index 1d19fddc..9f46de42 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIXChainBridge.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIXChainBridge.cs @@ -18,10 +18,22 @@ public class TestIXChainBridge : TestIXChainBridgeBase private static IXrplClient client; protected override IXrplClient GetClient() => client; + private static bool xchainEnabled; + [ClassInitialize] public static async Task ClassInitializeAsync(TestContext testContext) { client = await CreateStandaloneClient(); + xchainEnabled = await AmendmentGuard.IsEnabledAsync(client, AmendmentGuard.XChainBridge); + } + + [TestInitialize] + public void CheckXChainBridgeAmendment() + { + if (!xchainEnabled) + { + Assert.Inconclusive("XChainBridge amendment is not enabled on the test node."); + } } [ClassCleanup] diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIXChainBridgeBase.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIXChainBridgeBase.cs index c610bc7a..92be2022 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIXChainBridgeBase.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIXChainBridgeBase.cs @@ -19,7 +19,7 @@ public abstract class TestIXChainBridgeBase { public TestContext TestContext { get; set; } protected abstract IXrplClient GetClient(); - protected static TestNodeType nodeType = TestNodeType.Standalone; + protected static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; /// /// Genesis account address — required as IssuingChainDoor for XRP-XRP bridges in standalone mode. @@ -119,6 +119,6 @@ protected static async Task EnableDefaultRipple(IXrplClient client, XrplWallet i protected static async Task CreateStandaloneClient() { - return await IntegrationTestConfig.CreateClientAsync(TestNodeType.Standalone); + return await IntegrationTestConfig.CreateClientAsync(); } } From 00fb0a14756941da4a0ae488397b4075cf943439 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 08:31:55 -0300 Subject: [PATCH 02/26] fix(batch): refuse a Batch with a single inner transaction rippled Batch::preflight answers temARRAY_EMPTY to fewer than two inners - the same code as for none at all - while ValidateBatch only refused an empty RawTransactions. Five of sixteen 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. --- Tests/Xrpl.Tests/Wallet/TestUBatchCoSigning.cs | 10 ++++++++++ Xrpl/Models/Transactions/Batch.cs | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/Tests/Xrpl.Tests/Wallet/TestUBatchCoSigning.cs b/Tests/Xrpl.Tests/Wallet/TestUBatchCoSigning.cs index 92192a00..cbb5cc8e 100644 --- a/Tests/Xrpl.Tests/Wallet/TestUBatchCoSigning.cs +++ b/Tests/Xrpl.Tests/Wallet/TestUBatchCoSigning.cs @@ -138,6 +138,16 @@ public void TestUSign_OuterBatchSponsor_RoutesToSponsorSignature() Assert.IsNull(decoded["TxnSignature"]); } + [TestMethod] + public async Task TestUValidateBatch_SingleInner_Throws() + { + // rippled Batch::preflight answers temARRAY_EMPTY to fewer than two inners + Dictionary batch = ToDict(OuterBatch(InnerPayment(Other.ClassicAddress))); + + var ex = await Assert.ThrowsExactlyAsync(() => Validation.ValidateBatch(batch)); + StringAssert.Contains(ex.Message, "at least 2"); + } + [TestMethod] public async Task TestUValidateBatch_OuterReserveSponsorship_Throws() { diff --git a/Xrpl/Models/Transactions/Batch.cs b/Xrpl/Models/Transactions/Batch.cs index 1b8fd9eb..5f3eeb45 100644 --- a/Xrpl/Models/Transactions/Batch.cs +++ b/Xrpl/Models/Transactions/Batch.cs @@ -161,6 +161,10 @@ transactionTypeObj is not string transactionType || List rawTxs = rawTxsEnumerable.Cast().ToList(); if (rawTxs.Count == 0) throw new ArgumentException("Batch: RawTransactions is required and must be non-empty."); + // rippled Batch::preflight: a Batch wrapping a single transaction is rejected with + // temARRAY_EMPTY, the same code as for no inners at all - one inner is not a batch + if (rawTxs.Count < 2) + throw new ArgumentException("Batch: RawTransactions must contain at least 2 transactions (rippled answers temARRAY_EMPTY to a single inner)."); if (rawTxs.Count > 8) throw new ArgumentException("Batch: RawTransactions length must be <= 8."); From b7882633b700e146bcfcd196506073ec69de0210 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 08:31:55 -0300 Subject: [PATCH 03/26] test: coverage matrices for Batch inners, sponsored types and MPT AMM Three 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, which covered AMMCreate only). Two protocol facts the matrices surfaced are 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. --- .../transactions/TestIAMMCreateMpt.cs | 116 ---- .../Integration/transactions/TestIAMMMpt.cs | 317 ++++++++++ .../transactions/TestIBatchInnerTypes.cs | 511 ++++++++++++++++ .../transactions/TestISponsoredTypes.cs | 545 ++++++++++++++++++ 4 files changed, 1373 insertions(+), 116 deletions(-) delete mode 100644 Tests/Xrpl.Tests/Integration/transactions/TestIAMMCreateMpt.cs create mode 100644 Tests/Xrpl.Tests/Integration/transactions/TestIAMMMpt.cs create mode 100644 Tests/Xrpl.Tests/Integration/transactions/TestIBatchInnerTypes.cs create mode 100644 Tests/Xrpl.Tests/Integration/transactions/TestISponsoredTypes.cs diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIAMMCreateMpt.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIAMMCreateMpt.cs deleted file mode 100644 index 4e02be78..00000000 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIAMMCreateMpt.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System; -using System.Threading.Tasks; - -using Microsoft.VisualStudio.TestTools.UnitTesting; - -using Xrpl.Client; -using Xrpl.Client.Exceptions; -using Xrpl.Models.Common; -using Xrpl.Models.Methods; -using Xrpl.Models.Transactions; -using Xrpl.Sugar; -using Xrpl.Wallet; - -using static Xrpl.Models.Common.Common; - -namespace XrplTests.Xrpl.ClientLib.Integration; - -/// -/// Integration tests verifying AMM behavior with MPT (Multi-Purpose Token) assets. -/// With the featureMPTokensV2 amendment enabled, AMM supports MPT assets (XLS-62); -/// these tests verify that creating an MPT/XRP AMM pool succeeds. -/// XLS-62: https://github.com/XRPLF/XRPL-Standards/discussions/231 -/// NOTE: featureMPTokensV2 is In Development (not yet on Mainnet); the CI standalone node -/// enables it via rippled.cfg [features] to exercise this path ahead of release. -/// -[TestClass] -[TestCategory("AMM")] -public class TestIAMMCreateMpt -{ - public TestContext TestContext { get; set; } - - private static IXrplClient client; - private static readonly TestNodeType nodeType = TestNodeType.Standalone; - - [ClassInitialize] - public static async Task ClassInitializeAsync(TestContext testContext) - { - client = await IntegrationTestConfig.CreateClientAsync(TestNodeType.Standalone); - } - - [ClassCleanup] - public static void ClassCleanup() => client?.Dispose(); - - private static void AssertSuccess(TransactionSummary res, string context) - { - string result = res.Meta?.TransactionResult; - Assert.IsTrue( - result is "tesSUCCESS" or "terQUEUED", - $"{context} failed: {result}"); - } - - [TestMethod] - public async Task TestAMMCreate_MptXrpPool_Succeeds() - { - // With featureMPTokensV2 enabled, an MPT/XRP AMM pool can be created (XLS-62). - // Set up an MPT, fund a holder, then create the AMM and assert it succeeds. - XrplWallet walletIssuer = XrplWallet.Generate(); - XrplWallet walletHolder = XrplWallet.Generate(); - await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, walletIssuer, walletHolder); - - // Create MPT issuance - MPTokenIssuanceCreate mptCreateTx = new MPTokenIssuanceCreate - { - Account = walletIssuer.ClassicAddress, - // MPT must be tradable (lsfMPTCanTrade) for AMM/DEX use and transferable to move into the pool - Flags = MPTokenIssuanceCreateFlags.tfMPTCanTrade | MPTokenIssuanceCreateFlags.tfMPTCanTransfer, - }; - mptCreateTx = await client.Autofill(mptCreateTx); - TransactionSummary mptResult = await client.SubmitAndWait(mptCreateTx, walletIssuer, true); - AssertSuccess(mptResult, "MPTokenIssuanceCreate"); - - string issuanceId = mptResult.Meta?.MptIssuanceId; - Assert.IsNotNull(issuanceId, "MPTokenIssuanceID should be present in metadata"); - - // Authorize and fund holder - MPTokenAuthorize authTx = new MPTokenAuthorize - { - Account = walletHolder.ClassicAddress, - MPTokenIssuanceID = issuanceId, - }; - authTx = await client.Autofill(authTx); - TransactionSummary authResult = await client.SubmitAndWait(authTx, walletHolder, true); - AssertSuccess(authResult, "MPTokenAuthorize"); - - Payment paymentTx = new Payment - { - Account = walletIssuer.ClassicAddress, - Destination = walletHolder.ClassicAddress, - Amount = new Currency - { - Value = "10000", - MPTokenIssuanceID = issuanceId, - }, - }; - paymentTx = await client.Autofill(paymentTx); - TransactionSummary payResult = await client.SubmitAndWait(paymentTx, walletIssuer, true); - AssertSuccess(payResult, "MPT Payment"); - - // Create AMM pool with an MPT + XRP — succeeds when featureMPTokensV2 is enabled - AMMCreate ammCreate = new AMMCreate - { - Account = walletHolder.ClassicAddress, - Amount = new Currency - { - Value = "1000", - MPTokenIssuanceID = issuanceId, - }, - Amount2 = new Currency { ValueAsXrp = 10m }, - TradingFee = 500, - }; - ITransactionRequest autofilled = await client.Autofill(ammCreate); - - TransactionSummary ammResult = await client.SubmitAndWait(autofilled, walletHolder, true); - AssertSuccess(ammResult, "AMMCreate MPT/XRP pool"); - } -} diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIAMMMpt.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIAMMMpt.cs new file mode 100644 index 00000000..ae300eac --- /dev/null +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIAMMMpt.cs @@ -0,0 +1,317 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Client; +using Xrpl.Client.Exceptions; +using Xrpl.Models; +using Xrpl.Models.Common; +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; +using Xrpl.Models.Transactions; +using Xrpl.Sugar; +using Xrpl.Wallet; + +using static Xrpl.Models.Common.Common; + +namespace XrplTests.Xrpl.ClientLib.Integration; + +/// +/// AMM over MPT assets (XLS-62, featureMPTokensV2): every AMM transaction type with an +/// MPT in its Asset/Amount fields. The MPT issue goes through a different codec branch than +/// an IOU issue (STIssue with an MPTID instead of currency+issuer), so each transaction type +/// is exercised on its own rather than assumed from AMMCreate. +/// XLS-62: https://github.com/XRPLF/XRPL-Standards/discussions/231 +/// +[TestClass] +[TestCategory("AMM")] +public class TestIAMMMpt +{ + public TestContext TestContext { get; set; } + + private static IXrplClient client; + private static readonly TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; + private static bool mptokensV2Usable; + + private const string PoolMptAmount = "1000"; + private const decimal PoolXrpAmount = 10m; + + [ClassInitialize] + public static async Task ClassInitializeAsync(TestContext testContext) + { + client = await IntegrationTestConfig.CreateClientAsync(); + // On the standalone stands MPTokensV2 is a [features] Rules preset (Supported::No on the + // release build), invisible to the on-ledger guard: run there unconditionally and let a + // missing preset fail loudly with temDISABLED. On a public network trust the guard. + mptokensV2Usable = IntegrationTestConfig.IsStandalone() + || await AmendmentGuard.IsEnabledAsync(client, AmendmentGuard.MPTokensV2); + } + + [TestInitialize] + public void CheckMPTokensV2Amendment() + { + if (!mptokensV2Usable) + { + Assert.Inconclusive("MPTokensV2 amendment (XLS-62) is not enabled on the test node."); + } + } + + [ClassCleanup] + public static void ClassCleanup() => client?.Dispose(); + + private sealed record MptPool(XrplWallet Issuer, XrplWallet Holder, string IssuanceId); + + private static IssuedCurrency MptAsset(string issuanceId) => new IssuedCurrency { MptIssuanceId = issuanceId }; + + private static IssuedCurrency XrpAsset => new IssuedCurrency { Currency = "XRP" }; + + private static Currency Mpt(string issuanceId, string value) => new Currency { Value = value, MPTokenIssuanceID = issuanceId }; + + private static void AssertSuccess(TransactionSummary res, string context) + { + string result = res.Meta?.TransactionResult; + Assert.IsTrue( + result is "tesSUCCESS" or "terQUEUED", + $"{context} failed: {result}"); + } + + private static async Task SubmitAsync(ITransactionRequest tx, XrplWallet signer, string context) + { + ITransactionRequest autofilled = await client.Autofill(tx); + TransactionSummary res = await client.SubmitAndWait(autofilled, signer, true); + AssertSuccess(res, context); + return res; + } + + /// + /// Issues an MPT that can be traded on the AMM and hands 10 000 units. + /// + private static async Task IssueMptAsync(XrplWallet issuer, XrplWallet holder, MPTokenIssuanceCreateFlags extraFlags = 0) + { + MPTokenIssuanceCreate create = new MPTokenIssuanceCreate + { + Account = issuer.ClassicAddress, + Flags = MPTokenIssuanceCreateFlags.tfMPTCanTrade | MPTokenIssuanceCreateFlags.tfMPTCanTransfer | extraFlags, + }; + TransactionSummary createRes = await SubmitAsync(create, issuer, "MPTokenIssuanceCreate"); + string issuanceId = createRes.Meta?.MptIssuanceId; + Assert.IsNotNull(issuanceId, "MPTokenIssuanceID should be present in metadata"); + + await SubmitAsync(new MPTokenAuthorize + { + Account = holder.ClassicAddress, + MPTokenIssuanceID = issuanceId, + }, holder, "MPTokenAuthorize"); + + await SubmitAsync(new Payment + { + Account = issuer.ClassicAddress, + Destination = holder.ClassicAddress, + Amount = Mpt(issuanceId, "10000"), + }, issuer, "MPT Payment"); + + return issuanceId; + } + + private static async Task CreateMptXrpPoolAsync(MPTokenIssuanceCreateFlags extraFlags = 0) + { + XrplWallet issuer = XrplWallet.Generate(); + XrplWallet holder = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, issuer, holder); + + string issuanceId = await IssueMptAsync(issuer, holder, extraFlags); + + await SubmitAsync(new AMMCreate + { + Account = holder.ClassicAddress, + Amount = Mpt(issuanceId, PoolMptAmount), + Amount2 = new Currency { ValueAsXrp = PoolXrpAmount }, + TradingFee = 500, + }, holder, "AMMCreate MPT/XRP pool"); + + return new MptPool(issuer, holder, issuanceId); + } + + private static Task GetPoolInfoAsync(string issuanceId) => + client.AmmInfo(new AMMInfoRequest { Asset = MptAsset(issuanceId), Asset2 = XrpAsset }).Typed(); + + [TestMethod] + public async Task TestAMMCreate_MptXrpPool_Succeeds() + { + MptPool pool = await CreateMptXrpPoolAsync(); + + AMMInfoResponse info = await GetPoolInfoAsync(pool.IssuanceId); + Assert.IsTrue(info.Amm.LPTokenBalance.ValueAsNumber > 0, "the pool must have issued LP tokens"); + + // The AMM pseudo-account holds the pool's MPT through an MPToken entry flagged lsfMPTAMM + AccountObjects objects = await client.AccountObjects(new AccountObjectsRequest(info.Amm.Account) + { + Type = LedgerEntryType.MPToken, + }).Typed(); + LOMPToken poolToken = objects.AccountObjectList?.OfType() + .FirstOrDefault(t => string.Equals(t.MPTokenIssuanceID, pool.IssuanceId, StringComparison.OrdinalIgnoreCase)); + Assert.IsNotNull(poolToken, "the AMM account must hold an MPToken for the pool asset"); + Assert.IsTrue(poolToken.Flags is { } flags && flags.HasFlag(MPTokenFlags.lsfMPTAMM), + $"the AMM account's MPToken must carry lsfMPTAMM, got {poolToken.Flags}"); + } + + [TestMethod] + public async Task TestAMMCreate_MptMptPool_Succeeds() + { + XrplWallet issuer = XrplWallet.Generate(); + XrplWallet holder = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, issuer, holder); + + string first = await IssueMptAsync(issuer, holder); + string second = await IssueMptAsync(issuer, holder); + + await SubmitAsync(new AMMCreate + { + Account = holder.ClassicAddress, + Amount = Mpt(first, PoolMptAmount), + Amount2 = Mpt(second, PoolMptAmount), + TradingFee = 500, + }, holder, "AMMCreate MPT/MPT pool"); + + AMMInfoResponse info = await client.AmmInfo(new AMMInfoRequest { Asset = MptAsset(first), Asset2 = MptAsset(second) }).Typed(); + Assert.IsTrue(info.Amm.LPTokenBalance.ValueAsNumber > 0, "the MPT/MPT pool must have issued LP tokens"); + } + + [TestMethod] + public async Task TestAMMDeposit_Mpt_SingleAsset() + { + MptPool pool = await CreateMptXrpPoolAsync(); + decimal lpBefore = (await GetPoolInfoAsync(pool.IssuanceId)).Amm.LPTokenBalance.ValueAsNumber; + + await SubmitAsync(new AMMDeposit + { + Account = pool.Holder.ClassicAddress, + Asset = MptAsset(pool.IssuanceId), + Asset2 = XrpAsset, + Amount = Mpt(pool.IssuanceId, "100"), + Flags = AMMDepositFlags.tfSingleAsset, + }, pool.Holder, "AMMDeposit tfSingleAsset MPT"); + + decimal lpAfter = (await GetPoolInfoAsync(pool.IssuanceId)).Amm.LPTokenBalance.ValueAsNumber; + Assert.IsTrue(lpAfter > lpBefore, $"LP supply must grow after a single-asset MPT deposit ({lpBefore} -> {lpAfter})"); + } + + [TestMethod] + public async Task TestAMMDeposit_Mpt_TwoAsset() + { + MptPool pool = await CreateMptXrpPoolAsync(); + decimal lpBefore = (await GetPoolInfoAsync(pool.IssuanceId)).Amm.LPTokenBalance.ValueAsNumber; + + await SubmitAsync(new AMMDeposit + { + Account = pool.Holder.ClassicAddress, + Asset = MptAsset(pool.IssuanceId), + Asset2 = XrpAsset, + Amount = Mpt(pool.IssuanceId, "100"), + Amount2 = new Currency { ValueAsXrp = 1m }, + Flags = AMMDepositFlags.tfTwoAsset, + }, pool.Holder, "AMMDeposit tfTwoAsset MPT+XRP"); + + decimal lpAfter = (await GetPoolInfoAsync(pool.IssuanceId)).Amm.LPTokenBalance.ValueAsNumber; + Assert.IsTrue(lpAfter > lpBefore, $"LP supply must grow after a two-asset deposit ({lpBefore} -> {lpAfter})"); + } + + [TestMethod] + public async Task TestAMMWithdraw_Mpt_SingleAsset() + { + MptPool pool = await CreateMptXrpPoolAsync(); + decimal lpBefore = (await GetPoolInfoAsync(pool.IssuanceId)).Amm.LPTokenBalance.ValueAsNumber; + + await SubmitAsync(new AMMWithdraw + { + Account = pool.Holder.ClassicAddress, + Asset = MptAsset(pool.IssuanceId), + Asset2 = XrpAsset, + Amount = Mpt(pool.IssuanceId, "100"), + Flags = AMMWithdrawFlags.tfSingleAsset, + }, pool.Holder, "AMMWithdraw tfSingleAsset MPT"); + + decimal lpAfter = (await GetPoolInfoAsync(pool.IssuanceId)).Amm.LPTokenBalance.ValueAsNumber; + Assert.IsTrue(lpAfter < lpBefore, $"LP supply must shrink after a single-asset MPT withdrawal ({lpBefore} -> {lpAfter})"); + } + + [TestMethod] + public async Task TestAMMWithdraw_Mpt_WithdrawAll_RemovesPool() + { + MptPool pool = await CreateMptXrpPoolAsync(); + + await SubmitAsync(new AMMWithdraw + { + Account = pool.Holder.ClassicAddress, + Asset = MptAsset(pool.IssuanceId), + Asset2 = XrpAsset, + Flags = AMMWithdrawFlags.tfWithdrawAll, + }, pool.Holder, "AMMWithdraw tfWithdrawAll MPT"); + + // The sole LP holder withdrawing everything deletes the pool + try + { + AMMInfoResponse info = await GetPoolInfoAsync(pool.IssuanceId); + Assert.Fail($"the pool must be gone after the sole holder withdraws everything, LP balance: {info.Amm?.LPTokenBalance?.Value}"); + } + catch (RippledException ex) when (ex.Message.Contains("actNotFound") || ex.Message.Contains("ammNotFound")) + { + Console.WriteLine($"Pool removed after WithdrawAll: {ex.Message}"); + } + } + + [TestMethod] + public async Task TestAMMVote_Mpt() + { + MptPool pool = await CreateMptXrpPoolAsync(); + + await SubmitAsync(new AMMVote + { + Account = pool.Holder.ClassicAddress, + Asset = MptAsset(pool.IssuanceId), + Asset2 = XrpAsset, + TradingFee = 100, + }, pool.Holder, "AMMVote MPT pool"); + + AMMInfoResponse info = await GetPoolInfoAsync(pool.IssuanceId); + Assert.AreEqual(100u, info.Amm.TradingFee, "the sole LP holder's vote sets the trading fee"); + } + + [TestMethod] + public async Task TestAMMBid_Mpt() + { + MptPool pool = await CreateMptXrpPoolAsync(); + + await SubmitAsync(new AMMBid + { + Account = pool.Holder.ClassicAddress, + Asset = MptAsset(pool.IssuanceId), + Asset2 = XrpAsset, + }, pool.Holder, "AMMBid MPT pool"); + + AMMInfoResponse info = await GetPoolInfoAsync(pool.IssuanceId); + Assert.IsNotNull(info.Amm.AuctionSlot, "the auction slot must be taken after the bid"); + Assert.AreEqual(pool.Holder.ClassicAddress, info.Amm.AuctionSlot.Account, "the bidder must own the auction slot"); + } + + [TestMethod] + public async Task TestAMMClawback_Mpt() + { + MptPool pool = await CreateMptXrpPoolAsync(MPTokenIssuanceCreateFlags.tfMPTCanClawback); + decimal lpBefore = (await GetPoolInfoAsync(pool.IssuanceId)).Amm.LPTokenBalance.ValueAsNumber; + + await SubmitAsync(new AMMClawBack + { + Account = pool.Issuer.ClassicAddress, + Holder = pool.Holder.ClassicAddress, + Asset = MptAsset(pool.IssuanceId), + Asset2 = XrpAsset, + Amount = Mpt(pool.IssuanceId, "100"), + }, pool.Issuer, "AMMClawback MPT"); + + decimal lpAfter = (await GetPoolInfoAsync(pool.IssuanceId)).Amm.LPTokenBalance.ValueAsNumber; + Assert.IsTrue(lpAfter < lpBefore, $"clawing back pool MPT burns the holder's LP tokens ({lpBefore} -> {lpAfter})"); + } +} diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIBatchInnerTypes.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIBatchInnerTypes.cs new file mode 100644 index 00000000..e98492af --- /dev/null +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIBatchInnerTypes.cs @@ -0,0 +1,511 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.BinaryCodec; +using Xrpl.Client; +using Xrpl.Client.Exceptions; +using Xrpl.Client.Json; +using Xrpl.Models; +using Xrpl.Models.Common; +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; +using Xrpl.Models.Transactions; +using Xrpl.Models.Utils; +using Xrpl.Sugar; +using Xrpl.Utils; +using Xrpl.Utils.Hashes; +using Xrpl.Wallet; + +using static Xrpl.Models.Common.Common; + +namespace XrplTests.Xrpl.ClientLib.Integration; + +/// +/// Every transaction type the SDK can wrap as a Batch inner, exercised as one. The +/// batch normaliser rewrites each inner (flags, fee, signing key, sequence), and the +/// outer signature commits to the inner ids it computes, so a type that serialises +/// differently from Payment - a field the normaliser strips, an STIssue, an STArray - +/// can only be trusted once a node has validated it inside a Batch. Each test reads +/// every inner back by its computed id and checks the inner result the ledger recorded. +/// +[TestClass] +[TestCategory("Batch")] +public class TestIBatchInnerTypes +{ + private static bool batchV11Active; + + public TestContext TestContext { get; set; } + private static IXrplClient client; + private static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; + + private const string GenesisAccount = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"; + private const string TestCurrency = "USD"; + + private static readonly TimeSpan EscrowFinishMargin = TimeSpan.FromSeconds(12); + private static readonly TimeSpan EscrowCancelMargin = TimeSpan.FromSeconds(24); + + [ClassInitialize] + public static async Task ClassInitializeAsync(TestContext testContext) + { + client = await IntegrationTestConfig.CreateClientAsync(); + batchV11Active = await AmendmentGuard.IsEnabledAsync(client, AmendmentGuard.BatchV11); + } + + [TestInitialize] + public void CheckBatchV11Amendment() + { + if (!batchV11Active) + { + Assert.Inconclusive("BatchV1_1 amendment is not enabled on the test node."); + } + } + + [ClassCleanup] + public static void ClassCleanup() => client?.Dispose(); + + #region Helpers + + private sealed record InnerResult(string Hash, string TransactionType, string Result, Meta Meta); + + private static Dictionary Reparse(string blob) => + JsonSerializer.Deserialize>( + XrplBinaryCodec.Decode(blob).ToJsonString(), XrplJsonOptions.Default); + + private static async Task<(XrplWallet owner, XrplWallet peer)> FundPairAsync() + { + XrplWallet owner = XrplWallet.Generate(); + XrplWallet peer = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, owner, peer); + return (owner, peer); + } + + private static async Task NextSequenceAsync(XrplWallet wallet) + { + AccountInfo info = await client.AccountInfo(new AccountInfoRequest(wallet.ClassicAddress) + { + LedgerIndex = new LedgerIndex(LedgerIndexType.Current), + }).Typed(); + return info.AccountData.Sequence ?? throw new InvalidOperationException($"account_info for {wallet.ClassicAddress} carries no Sequence"); + } + + private static async Task ValidatedCloseTimeAsync() + { + LOLedger ledger = await client.Ledger(new LedgerRequest { LedgerIndex = new LedgerIndex(LedgerIndexType.Validated) }).Typed(); + LedgerEntity entity = (LedgerEntity)ledger.LedgerEntity; + return entity.CloseTime ?? throw new InvalidOperationException("validated ledger has no close_time"); + } + + private static async Task WaitForCloseTimeAsync(DateTime target) + { + while (await ValidatedCloseTimeAsync() < target) + { + await Task.Delay(TimeSpan.FromSeconds(3)); + } + } + + private static async Task SubmitPlainAsync(ITransactionRequest tx, XrplWallet signer, string context) + { + ITransactionRequest autofilled = await client.Autofill(tx); + TransactionSummary res = await client.SubmitAndWait(autofilled, signer, true); + if (res is not { Meta: { TransactionResult: "tesSUCCESS" or "terQUEUED" } }) + throw new RippleException($"{context} failed: {res.Meta?.TransactionResult}"); + } + + private static Batch NewBatch(XrplWallet outer, BatchFlags flags, params RawTransactionWrapper[] inners) => new Batch + { + Account = outer.ClassicAddress, + Flags = flags, + RawTransactions = inners.ToList(), + }; + + /// + /// Autofills, signs (batch signers first, the outer account last), submits and + /// waits for the outer Batch, then reads every inner back by its computed id. + /// + private static async Task> SubmitBatchAsync(Batch batch, XrplWallet outer, params XrplWallet[] batchSigners) + { + Batch autofilled = await client.Autofill(batch); + Dictionary current = autofilled.ToDictionary(); + + foreach (XrplWallet signer in batchSigners) + { + SignatureResult part = signer.Sign(current); + current = Reparse(part.TxBlob); + } + SignatureResult final = outer.Sign(current); + + TransactionSummary res = await client.SubmitRequestAndWait(final.TxBlob, false); + Assert.IsTrue(res.Meta?.TransactionResult is "tesSUCCESS", + $"outer Batch must validate with tesSUCCESS, got {res.Meta?.TransactionResult}"); + + JsonObject signed = XrplBinaryCodec.Decode(final.TxBlob).AsObject(); + List inners = new List(); + foreach (JsonNode wrapper in signed["RawTransactions"].AsArray()) + { + JsonObject inner = wrapper["RawTransaction"].AsObject(); + string hash = inner.ComputeInnerTxId().ToUpperInvariant(); + TransactionResponse innerTx = await client.TxV1(new TxRequest(hash)).Typed(); + inners.Add(new InnerResult(hash, inner["TransactionType"].GetValue(), innerTx.Meta?.TransactionResult, innerTx.Meta)); + } + return inners; + } + + private static void AssertAllInnersSucceeded(IReadOnlyList inners) + { + foreach (InnerResult inner in inners) + { + Assert.AreEqual("tesSUCCESS", inner.Result, $"inner {inner.TransactionType} {inner.Hash} must validate with tesSUCCESS"); + } + } + + private static string CreatedIndex(Meta meta, LedgerEntryType type) => + meta?.AffectedNodes? + .Select(n => n.CreatedNode) + .FirstOrDefault(c => c is { } && c.LedgerEntryType == type)?.LedgerIndex + ?? throw new AssertFailedException($"metadata carries no created {type} node"); + + private static string ToHex(string text) => Convert.ToHexString(System.Text.Encoding.UTF8.GetBytes(text)); + + /// + /// A no-op AccountSet: rippled rejects a Batch with fewer than two inners + /// (temARRAY_EMPTY), so a single interesting inner travels with this one. + /// + private static RawTransactionWrapper Touch(XrplWallet wallet) => new AccountSet { Account = wallet.ClassicAddress }.ToBatchTx(); + + #endregion + + [TestMethod] + public async Task Batch_Inner_DIDSet_DIDDelete() + { + (XrplWallet owner, _) = await FundPairAsync(); + + Batch batch = NewBatch(owner, BatchFlags.tfAllOrNothing, + new DIDSet { Account = owner.ClassicAddress, Data = ToHex("batch did") }.ToBatchTx(), + new DIDDelete { Account = owner.ClassicAddress }.ToBatchTx()); + + AssertAllInnersSucceeded(await SubmitBatchAsync(batch, owner)); + } + + [TestMethod] + public async Task Batch_Inner_OracleSet_OracleDelete() + { + (XrplWallet owner, _) = await FundPairAsync(); + DateTime closeTime = await ValidatedCloseTimeAsync(); + const uint documentId = 7; + + OracleSet set = new OracleSet + { + Account = owner.ClassicAddress, + OracleDocumentID = documentId, + LastUpdateTime = closeTime, + Provider = "batch", + AssetClass = "currency", + PriceDataSeries = new List + { + new PriceDataWrapper { PriceData = new PriceData { BaseAsset = "XRP", QuoteAsset = "USD", AssetPrice = 740, Scale = 3 } }, + }, + }; + OracleDelete delete = new OracleDelete { Account = owner.ClassicAddress, OracleDocumentID = documentId }; + + Batch batch = NewBatch(owner, BatchFlags.tfAllOrNothing, set.ToBatchTx(), delete.ToBatchTx()); + AssertAllInnersSucceeded(await SubmitBatchAsync(batch, owner)); + } + + [TestMethod] + public async Task Batch_Inner_CredentialCreate_Accept_Delete() + { + (XrplWallet owner, XrplWallet peer) = await FundPairAsync(); + string credentialType = ToHex("batch_credential"); + + Batch batch = NewBatch(owner, BatchFlags.tfAllOrNothing, + new CredentialCreate { Account = owner.ClassicAddress, Subject = peer.ClassicAddress, CredentialType = credentialType }.ToBatchTx(), + new CredentialAccept { Account = peer.ClassicAddress, Issuer = owner.ClassicAddress, CredentialType = credentialType }.ToBatchTx(), + new CredentialDelete { Account = owner.ClassicAddress, Subject = peer.ClassicAddress, Issuer = owner.ClassicAddress, CredentialType = credentialType }.ToBatchTx()); + + AssertAllInnersSucceeded(await SubmitBatchAsync(batch, owner, peer)); + } + + [TestMethod] + public async Task Batch_Inner_MPTokenIssuance_Create_Set_Authorize_Destroy() + { + (XrplWallet owner, XrplWallet peer) = await FundPairAsync(); + + // The issuance id is derived from the create's sequence, which the normaliser + // assigns as the outer sequence + 1 for the first inner of the outer account + uint outerSequence = await NextSequenceAsync(owner); + string issuanceId = ParseMPTID.GenerateMPTokenIssuanceID(outerSequence + 1, owner.ClassicAddress); + + Batch batch = NewBatch(owner, BatchFlags.tfAllOrNothing, + new MPTokenIssuanceCreate { Account = owner.ClassicAddress, Flags = MPTokenIssuanceCreateFlags.tfMPTCanLock | MPTokenIssuanceCreateFlags.tfMPTCanTransfer }.ToBatchTx(), + new MPTokenIssuanceSet { Account = owner.ClassicAddress, MPTokenIssuanceID = issuanceId, Flags = MPTokenIssuanceSetFlags.tfMPTLock }.ToBatchTx(), + new MPTokenAuthorize { Account = peer.ClassicAddress, MPTokenIssuanceID = issuanceId }.ToBatchTx(), + new MPTokenAuthorize { Account = peer.ClassicAddress, MPTokenIssuanceID = issuanceId, Flags = MPTokenAuthorizeFlags.tfMPTUnauthorize }.ToBatchTx(), + new MPTokenIssuanceDestroy { Account = owner.ClassicAddress, MPTokenIssuanceID = issuanceId }.ToBatchTx()); + batch.Sequence = outerSequence; + + AssertAllInnersSucceeded(await SubmitBatchAsync(batch, owner, peer)); + } + + [TestMethod] + public async Task Batch_Inner_NFToken_Mint_Offers_Cancel_Modify_Accept_Burn() + { + (XrplWallet owner, XrplWallet peer) = await FundPairAsync(); + + List mint = await SubmitBatchAsync(NewBatch(owner, BatchFlags.tfAllOrNothing, + new NFTokenMint { Account = owner.ClassicAddress, NFTokenTaxon = 0, Flags = NFTokenMintFlags.tfTransferable | NFTokenMintFlags.tfMutable }.ToBatchTx(), + Touch(owner)), owner); + AssertAllInnersSucceeded(mint); + string nftokenId = mint[0].Meta?.NFTokenId; + Assert.IsNotNull(nftokenId, "the inner NFTokenMint must report nftoken_id"); + + List offers = await SubmitBatchAsync(NewBatch(owner, BatchFlags.tfAllOrNothing, + new NFTokenCreateOffer { Account = owner.ClassicAddress, NFTokenID = nftokenId, Amount = new Currency { ValueAsXrp = 1m }, Flags = NFTokenCreateOfferFlags.tfSellNFToken }.ToBatchTx(), + new NFTokenCreateOffer { Account = owner.ClassicAddress, NFTokenID = nftokenId, Amount = new Currency { ValueAsXrp = 2m }, Flags = NFTokenCreateOfferFlags.tfSellNFToken }.ToBatchTx()), owner); + AssertAllInnersSucceeded(offers); + string sellOffer = offers[0].Meta?.OfferID; + string staleOffer = offers[1].Meta?.OfferID; + Assert.IsNotNull(sellOffer, "the inner NFTokenCreateOffer must report offer_id"); + Assert.IsNotNull(staleOffer, "the second inner NFTokenCreateOffer must report offer_id"); + + List transfer = await SubmitBatchAsync(NewBatch(owner, BatchFlags.tfAllOrNothing, + new NFTokenCancelOffer { Account = owner.ClassicAddress, NFTokenOffers = new[] { staleOffer } }.ToBatchTx(), + new NFTokenModify { Account = owner.ClassicAddress, NFTokenID = nftokenId, URI = ToHex("ipfs://batch") }.ToBatchTx(), + new NFTokenAcceptOffer { Account = peer.ClassicAddress, NFTokenSellOffer = sellOffer }.ToBatchTx()), owner, peer); + AssertAllInnersSucceeded(transfer); + + List burn = await SubmitBatchAsync(NewBatch(peer, BatchFlags.tfAllOrNothing, + new NFTokenBurn { Account = peer.ClassicAddress, NFTokenID = nftokenId }.ToBatchTx(), + Touch(peer)), peer); + AssertAllInnersSucceeded(burn); + } + + [TestMethod] + public async Task Batch_Inner_CheckCreate_CheckCancel_CheckCash() + { + (XrplWallet owner, XrplWallet peer) = await FundPairAsync(); + + List created = await SubmitBatchAsync(NewBatch(owner, BatchFlags.tfAllOrNothing, + new CheckCreate { Account = owner.ClassicAddress, Destination = peer.ClassicAddress, SendMax = new Currency { ValueAsXrp = 1m } }.ToBatchTx(), + new CheckCreate { Account = owner.ClassicAddress, Destination = peer.ClassicAddress, SendMax = new Currency { ValueAsXrp = 2m } }.ToBatchTx()), owner); + AssertAllInnersSucceeded(created); + string cashable = CreatedIndex(created[0].Meta, LedgerEntryType.Check); + string cancellable = CreatedIndex(created[1].Meta, LedgerEntryType.Check); + + List settled = await SubmitBatchAsync(NewBatch(owner, BatchFlags.tfAllOrNothing, + new CheckCancel { Account = owner.ClassicAddress, CheckID = cancellable }.ToBatchTx(), + new CheckCash { Account = peer.ClassicAddress, CheckID = cashable, Amount = new Currency { ValueAsXrp = 1m } }.ToBatchTx()), owner, peer); + AssertAllInnersSucceeded(settled); + } + + [TestMethod] + public async Task Batch_Inner_EscrowCreate_EscrowFinish_EscrowCancel() + { + (XrplWallet owner, XrplWallet peer) = await FundPairAsync(); + DateTime closeTime = await ValidatedCloseTimeAsync(); + uint outerSequence = await NextSequenceAsync(owner); + + Batch create = NewBatch(owner, BatchFlags.tfAllOrNothing, + new EscrowCreate { Account = owner.ClassicAddress, Destination = peer.ClassicAddress, Amount = new Currency { ValueAsXrp = 1m }, FinishAfter = closeTime + EscrowFinishMargin }.ToBatchTx(), + new EscrowCreate { Account = owner.ClassicAddress, Destination = peer.ClassicAddress, Amount = new Currency { ValueAsXrp = 1m }, FinishAfter = closeTime + EscrowFinishMargin, CancelAfter = closeTime + EscrowCancelMargin }.ToBatchTx()); + create.Sequence = outerSequence; + AssertAllInnersSucceeded(await SubmitBatchAsync(create, owner)); + + await WaitForCloseTimeAsync(closeTime + EscrowCancelMargin); + + Batch settle = NewBatch(owner, BatchFlags.tfAllOrNothing, + new EscrowFinish { Account = owner.ClassicAddress, Owner = owner.ClassicAddress, OfferSequence = outerSequence + 1 }.ToBatchTx(), + new EscrowCancel { Account = owner.ClassicAddress, Owner = owner.ClassicAddress, OfferSequence = outerSequence + 2 }.ToBatchTx()); + AssertAllInnersSucceeded(await SubmitBatchAsync(settle, owner)); + } + + [TestMethod] + public async Task Batch_Inner_PaymentChannelCreate_Fund_Claim() + { + (XrplWallet owner, XrplWallet peer) = await FundPairAsync(); + uint outerSequence = await NextSequenceAsync(owner); + string channel = Hashes.HashPaymentChannel(owner.ClassicAddress, peer.ClassicAddress, (int)outerSequence + 1); + + Batch open = NewBatch(owner, BatchFlags.tfAllOrNothing, + new PaymentChannelCreate { Account = owner.ClassicAddress, Destination = peer.ClassicAddress, Amount = "1000000", SettleDelay = 60, PublicKey = owner.PublicKey }.ToBatchTx(), + Touch(owner)); + open.Sequence = outerSequence; + AssertAllInnersSucceeded(await SubmitBatchAsync(open, owner)); + + // The channel source may claim without a signature, so both moves fit one batch + Batch use = NewBatch(owner, BatchFlags.tfAllOrNothing, + new PaymentChannelFund { Account = owner.ClassicAddress, Channel = channel, Amount = "500000" }.ToBatchTx(), + new PaymentChannelClaim { Account = owner.ClassicAddress, Channel = channel, Balance = "700000" }.ToBatchTx()); + AssertAllInnersSucceeded(await SubmitBatchAsync(use, owner)); + } + + [TestMethod] + public async Task Batch_Inner_OfferCreate_OfferCancel() + { + (XrplWallet owner, XrplWallet peer) = await FundPairAsync(); + uint outerSequence = await NextSequenceAsync(owner); + + Batch batch = NewBatch(owner, BatchFlags.tfAllOrNothing, + new OfferCreate + { + Account = owner.ClassicAddress, + TakerGets = new Currency { ValueAsXrp = 1m }, + TakerPays = new Currency { CurrencyCode = TestCurrency, Issuer = peer.ClassicAddress, Value = "1" }, + }.ToBatchTx(), + new OfferCancel { Account = owner.ClassicAddress, OfferSequence = outerSequence + 1 }.ToBatchTx()); + batch.Sequence = outerSequence; + + AssertAllInnersSucceeded(await SubmitBatchAsync(batch, owner)); + } + + [TestMethod] + public async Task Batch_Inner_PermissionedDomainSet_Delete() + { + (XrplWallet owner, XrplWallet peer) = await FundPairAsync(); + + List created = await SubmitBatchAsync(NewBatch(owner, BatchFlags.tfAllOrNothing, + new PermissionedDomainSet + { + Account = owner.ClassicAddress, + AcceptedCredentials = new List + { + new AcceptedCredentialWrapper { Credential = new AcceptedCredential { Issuer = peer.ClassicAddress, CredentialType = ToHex("batch_domain") } }, + }, + }.ToBatchTx(), + Touch(owner)), owner); + AssertAllInnersSucceeded(created); + string domainId = CreatedIndex(created[0].Meta, LedgerEntryType.PermissionedDomain); + + AssertAllInnersSucceeded(await SubmitBatchAsync(NewBatch(owner, BatchFlags.tfAllOrNothing, + new PermissionedDomainDelete { Account = owner.ClassicAddress, DomainID = domainId }.ToBatchTx(), + Touch(owner)), owner)); + } + + [TestMethod] + public async Task Batch_Inner_DelegateSet() + { + (XrplWallet owner, XrplWallet peer) = await FundPairAsync(); + + Batch batch = NewBatch(owner, BatchFlags.tfAllOrNothing, + new DelegateSet + { + Account = owner.ClassicAddress, + Authorize = peer.ClassicAddress, + Permissions = new List { new PermissionWrapper { Permission = new PermissionEntry { PermissionValue = 1 } } }, + }.ToBatchTx(), + Touch(owner)); + + AssertAllInnersSucceeded(await SubmitBatchAsync(batch, owner)); + } + + [TestMethod] + public async Task Batch_Inner_TrustSet_Payment_Clawback() + { + (XrplWallet owner, XrplWallet peer) = await FundPairAsync(); + await SubmitPlainAsync(new AccountSet { Account = owner.ClassicAddress, SetFlag = AccountSetAsfFlags.asfAllowTrustLineClawback }, owner, "asfAllowTrustLineClawback"); + + Batch batch = NewBatch(owner, BatchFlags.tfAllOrNothing, + new TrustSet { Account = peer.ClassicAddress, LimitAmount = new Currency { CurrencyCode = TestCurrency, Issuer = owner.ClassicAddress, Value = "1000" } }.ToBatchTx(), + new Payment { Account = owner.ClassicAddress, Destination = peer.ClassicAddress, Amount = new Currency { CurrencyCode = TestCurrency, Issuer = owner.ClassicAddress, Value = "100" } }.ToBatchTx(), + // Clawback names the holder as the issuer of the amount being clawed back + new ClawBack { Account = owner.ClassicAddress, Amount = new Currency { CurrencyCode = TestCurrency, Issuer = peer.ClassicAddress, Value = "40" } }.ToBatchTx()); + + AssertAllInnersSucceeded(await SubmitBatchAsync(batch, owner, peer)); + } + + [TestMethod] + public async Task Batch_Inner_AMMCreate_Deposit_Vote_Bid_Withdraw_Clawback() + { + (XrplWallet issuer, XrplWallet holder) = await FundPairAsync(); + await SubmitPlainAsync(new AccountSet { Account = issuer.ClassicAddress, SetFlag = AccountSetAsfFlags.asfAllowTrustLineClawback }, issuer, "asfAllowTrustLineClawback"); + await SubmitPlainAsync(new AccountSet { Account = issuer.ClassicAddress, SetFlag = AccountSetAsfFlags.asfDefaultRipple }, issuer, "asfDefaultRipple"); + await SubmitPlainAsync(new TrustSet { Account = holder.ClassicAddress, LimitAmount = new Currency { CurrencyCode = TestCurrency, Issuer = issuer.ClassicAddress, Value = "1000000" } }, holder, "TrustSet"); + await SubmitPlainAsync(new Payment { Account = issuer.ClassicAddress, Destination = holder.ClassicAddress, Amount = new Currency { CurrencyCode = TestCurrency, Issuer = issuer.ClassicAddress, Value = "10000" } }, issuer, "issue tokens"); + + IssuedCurrency token = new IssuedCurrency { Currency = TestCurrency, Issuer = issuer.ClassicAddress }; + IssuedCurrency xrp = new IssuedCurrency { Currency = "XRP" }; + + Batch create = NewBatch(holder, BatchFlags.tfAllOrNothing, + new AMMCreate + { + Account = holder.ClassicAddress, + Amount = new Currency { CurrencyCode = TestCurrency, Issuer = issuer.ClassicAddress, Value = "1000" }, + Amount2 = new Currency { ValueAsXrp = 10m }, + TradingFee = 500, + }.ToBatchTx(), + Touch(holder)); + AssertAllInnersSucceeded(await SubmitBatchAsync(create, holder)); + + Batch operate = NewBatch(holder, BatchFlags.tfAllOrNothing, + new AMMDeposit { Account = holder.ClassicAddress, Asset = token, Asset2 = xrp, Amount = new Currency { CurrencyCode = TestCurrency, Issuer = issuer.ClassicAddress, Value = "100" }, Flags = AMMDepositFlags.tfSingleAsset }.ToBatchTx(), + new AMMVote { Account = holder.ClassicAddress, Asset = token, Asset2 = xrp, TradingFee = 100 }.ToBatchTx(), + new AMMBid { Account = holder.ClassicAddress, Asset = token, Asset2 = xrp }.ToBatchTx(), + new AMMWithdraw { Account = holder.ClassicAddress, Asset = token, Asset2 = xrp, Amount = new Currency { CurrencyCode = TestCurrency, Issuer = issuer.ClassicAddress, Value = "50" }, Flags = AMMWithdrawFlags.tfSingleAsset }.ToBatchTx(), + new AMMClawBack { Account = issuer.ClassicAddress, Holder = holder.ClassicAddress, Asset = token, Asset2 = xrp, Amount = new Currency { CurrencyCode = TestCurrency, Issuer = issuer.ClassicAddress, Value = "100" } }.ToBatchTx()); + AssertAllInnersSucceeded(await SubmitBatchAsync(operate, holder, issuer)); + } + + [TestMethod] + public async Task Batch_Inner_XChain_CreateBridge_Modify_ClaimID_Commit_AccountCreateCommit() + { + (XrplWallet door, XrplWallet user) = await FundPairAsync(); + XChainBridgeModel bridge = new XChainBridgeModel + { + LockingChainDoor = door.ClassicAddress, + LockingChainIssue = new IssuedCurrency { Currency = "XRP" }, + IssuingChainDoor = GenesisAccount, + IssuingChainIssue = new IssuedCurrency { Currency = "XRP" }, + }; + Currency reward(string drops) => new Currency { Value = drops, CurrencyCode = "XRP" }; + + Batch batch = NewBatch(door, BatchFlags.tfAllOrNothing, + new XChainCreateBridge { Account = door.ClassicAddress, XChainBridge = bridge, SignatureReward = reward("100"), MinAccountCreateAmount = reward("10000000") }.ToBatchTx(), + new XChainModifyBridge { Account = door.ClassicAddress, XChainBridge = bridge, SignatureReward = reward("200") }.ToBatchTx(), + new XChainCreateClaimID { Account = user.ClassicAddress, XChainBridge = bridge, SignatureReward = reward("200"), OtherChainSource = user.ClassicAddress }.ToBatchTx(), + new XChainCommit { Account = user.ClassicAddress, XChainBridge = bridge, XChainClaimID = "1", Amount = reward("1000000"), OtherChainDestination = XrplWallet.Generate().ClassicAddress }.ToBatchTx(), + new XChainAccountCreateCommit { Account = user.ClassicAddress, XChainBridge = bridge, Destination = XrplWallet.Generate().ClassicAddress, Amount = reward("20000000"), SignatureReward = reward("200") }.ToBatchTx()); + + AssertAllInnersSucceeded(await SubmitBatchAsync(batch, door, user)); + } + + /// + /// tfIndependent applies every inner on its own: a healthy account has nothing for + /// LedgerStateFix to repair, so that inner is recorded with tecFAILED_PROCESSING + /// while its sibling and the outer Batch still validate. + /// + [TestMethod] + public async Task Batch_Inner_LedgerStateFix_Independent_RecordsInnerTec() + { + (XrplWallet owner, _) = await FundPairAsync(); + + Batch batch = NewBatch(owner, BatchFlags.tfIndependent, + new AccountSet { Account = owner.ClassicAddress, Domain = ToHex("batch.example") }.ToBatchTx(), + new LedgerStateFix { Account = owner.ClassicAddress, LedgerFixType = 1, Owner = owner.ClassicAddress }.ToBatchTx()); + + List inners = await SubmitBatchAsync(batch, owner); + Assert.AreEqual("tesSUCCESS", inners[0].Result, "the AccountSet inner must validate"); + Assert.IsTrue(inners[1].Result is "tesSUCCESS" or "tecFAILED_PROCESSING", + $"the LedgerStateFix inner must be recorded with its own result, got {inners[1].Result}"); + } + + [TestMethod] + public async Task Batch_Inner_SponsorshipSet_Create_Delete() + { + if (!await AmendmentGuard.IsEnabledAsync(client, AmendmentGuard.Sponsor)) + { + Assert.Inconclusive("Sponsor amendment (XLS-68) is not enabled on the test node."); + } + (XrplWallet owner, XrplWallet peer) = await FundPairAsync(); + + Batch batch = NewBatch(owner, BatchFlags.tfAllOrNothing, + new SponsorshipSet { Account = owner.ClassicAddress, Sponsee = peer.ClassicAddress, FeeAmountDelta = new Currency { ValueAsXrp = 1m }, RemainingOwnerCountDelta = 1 }.ToBatchTx(), + new SponsorshipSet { Account = owner.ClassicAddress, Sponsee = peer.ClassicAddress, Flags = SponsorshipSetFlags.tfDeleteObject }.ToBatchTx()); + + AssertAllInnersSucceeded(await SubmitBatchAsync(batch, owner)); + } +} diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredTypes.cs b/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredTypes.cs new file mode 100644 index 00000000..1155a462 --- /dev/null +++ b/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredTypes.cs @@ -0,0 +1,545 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Client; +using Xrpl.Client.Exceptions; +using Xrpl.Models; +using Xrpl.Models.Common; +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; +using Xrpl.Models.Transactions; +using Xrpl.Sugar; +using Xrpl.Utils.Hashes; +using Xrpl.Wallet; + +using static Xrpl.Models.Common.Common; + +namespace XrplTests.Xrpl.ClientLib.Integration; + +/// +/// The Sponsor field (XLS-68) on every transaction type other than Payment. Sponsor and +/// SponsorFlags live on the shared transaction base, so the SDK emits them for any type; +/// what a node makes of them differs per transactor (reserve-holding objects, deferred +/// fees, tec paths), and only a validated transaction of each type proves the pairing. +/// The sponsor always co-signs through SubmitAndWaitSponsored. +/// +[TestClass] +[TestCategory("Sponsorship")] +public class TestISponsoredTypes +{ + private static bool sponsorAmendmentActive; + + public TestContext TestContext { get; set; } + private static IXrplClient client; + private static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; + + private const string GenesisAccount = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"; + private const string TestCurrency = "USD"; + + private static readonly TimeSpan EscrowFinishMargin = TimeSpan.FromSeconds(12); + private static readonly TimeSpan EscrowCancelMargin = TimeSpan.FromSeconds(24); + + [ClassInitialize] + public static async Task ClassInitializeAsync(TestContext testContext) + { + client = await IntegrationTestConfig.CreateClientAsync(); + sponsorAmendmentActive = await AmendmentGuard.IsEnabledAsync(client, AmendmentGuard.Sponsor); + } + + [TestInitialize] + public void CheckSponsorAmendment() + { + if (!sponsorAmendmentActive) + { + Assert.Inconclusive("Sponsor amendment (XLS-68) is not enabled on the test node."); + } + } + + [ClassCleanup] + public static void ClassCleanup() => client?.Dispose(); + + #region Helpers + + /// + /// Funds a sponsor and a sponsee and opens a sponsorship generous enough for a whole test. + /// + private static async Task<(XrplWallet sponsor, XrplWallet sponsee)> SetupSponsorshipAsync() + { + XrplWallet sponsor = XrplWallet.Generate(); + XrplWallet sponsee = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, sponsor, sponsee); + await OpenSponsorshipAsync(sponsor, sponsee); + return (sponsor, sponsee); + } + + /// + /// Opens the sponsorship. The Sponsorship entry lands in the sponsee's owner + /// directory as well, so anything that needs an empty directory (asfAllowTrustLineClawback) + /// has to happen before this call. + /// + private static async Task OpenSponsorshipAsync(XrplWallet sponsor, XrplWallet sponsee) + { + await SubmitPlainAsync(new SponsorshipSet + { + Account = sponsor.ClassicAddress, + Sponsee = sponsee.ClassicAddress, + FeeAmountDelta = new Currency { ValueAsXrp = 20m }, + RemainingOwnerCountDelta = 10, + }, sponsor, "SponsorshipSet"); + } + + private static async Task SubmitPlainAsync(ITransactionRequest tx, XrplWallet signer, string context) + { + ITransactionRequest autofilled = await client.Autofill(tx); + TransactionSummary res = await client.SubmitAndWait(autofilled, signer, true); + if (res is not { Meta: { TransactionResult: "tesSUCCESS" or "terQUEUED" } }) + throw new RippleException($"{context} failed: {res.Meta?.TransactionResult}"); + return res; + } + + /// + /// Stamps the sponsor onto the transaction and submits it with both signatures. + /// + private static async Task SubmitSponsoredAsync( + T tx, XrplWallet sponsee, XrplWallet sponsor, SponsorCoverage coverage = SponsorCoverage.spfSponsorFee) + where T : TransactionRequest + { + Assert.AreEqual(sponsee.ClassicAddress, tx.Account, "the sponsored transaction must be the sponsee's"); + tx.Sponsor = sponsor.ClassicAddress; + tx.SponsorFlags = coverage; + + TransactionSummary res = await client.SubmitAndWaitSponsored(tx, sponsee, sponsor); + Assert.IsTrue(res.Meta?.TransactionResult is "tesSUCCESS", + $"sponsored {tx.TransactionType} must validate with tesSUCCESS, got {res.Meta?.TransactionResult}"); + return res; + } + + private static async Task ValidatedCloseTimeAsync() + { + LOLedger ledger = await client.Ledger(new LedgerRequest { LedgerIndex = new LedgerIndex(LedgerIndexType.Validated) }).Typed(); + LedgerEntity entity = (LedgerEntity)ledger.LedgerEntity; + return entity.CloseTime ?? throw new InvalidOperationException("validated ledger has no close_time"); + } + + private static async Task WaitForCloseTimeAsync(DateTime target) + { + while (await ValidatedCloseTimeAsync() < target) + { + await Task.Delay(TimeSpan.FromSeconds(3)); + } + } + + private static string ToHex(string text) => Convert.ToHexString(System.Text.Encoding.UTF8.GetBytes(text)); + + private static Currency Xrp(decimal value) => new Currency { ValueAsXrp = value }; + + private static Currency Drops(string drops) => new Currency { Value = drops, CurrencyCode = "XRP" }; + + #endregion + + [TestMethod] + public async Task Sponsored_AccountSet() + { + (XrplWallet sponsor, XrplWallet sponsee) = await SetupSponsorshipAsync(); + await SubmitSponsoredAsync(new AccountSet { Account = sponsee.ClassicAddress, Domain = ToHex("sponsored.example") }, sponsee, sponsor); + } + + [TestMethod] + public async Task Sponsored_TrustSet_ReserveCovered() + { + (XrplWallet sponsor, XrplWallet sponsee) = await SetupSponsorshipAsync(); + await SubmitSponsoredAsync(new TrustSet + { + Account = sponsee.ClassicAddress, + LimitAmount = new Currency { CurrencyCode = TestCurrency, Issuer = sponsor.ClassicAddress, Value = "1000" }, + }, sponsee, sponsor, SponsorCoverage.spfSponsorFee | SponsorCoverage.spfSponsorReserve); + } + + [TestMethod] + public async Task Sponsored_OfferCreate_OfferCancel() + { + (XrplWallet sponsor, XrplWallet sponsee) = await SetupSponsorshipAsync(); + + TransactionSummary offer = await SubmitSponsoredAsync(new OfferCreate + { + Account = sponsee.ClassicAddress, + TakerGets = Xrp(1m), + TakerPays = new Currency { CurrencyCode = TestCurrency, Issuer = sponsor.ClassicAddress, Value = "1" }, + }, sponsee, sponsor); + + await SubmitSponsoredAsync(new OfferCancel { Account = sponsee.ClassicAddress, OfferSequence = offer.Transaction.Sequence }, sponsee, sponsor); + } + + [TestMethod] + public async Task Sponsored_DIDSet_DIDDelete() + { + (XrplWallet sponsor, XrplWallet sponsee) = await SetupSponsorshipAsync(); + // A DID entry carries no Sponsor field, so only the fee can be sponsored + // (spfSponsorReserve on DIDSet is temINVALID_FLAG) + await SubmitSponsoredAsync(new DIDSet { Account = sponsee.ClassicAddress, Data = ToHex("sponsored did") }, sponsee, sponsor); + await SubmitSponsoredAsync(new DIDDelete { Account = sponsee.ClassicAddress }, sponsee, sponsor); + } + + [TestMethod] + public async Task Sponsored_OracleSet_OracleDelete() + { + (XrplWallet sponsor, XrplWallet sponsee) = await SetupSponsorshipAsync(); + const uint documentId = 11; + + await SubmitSponsoredAsync(new OracleSet + { + Account = sponsee.ClassicAddress, + OracleDocumentID = documentId, + LastUpdateTime = await ValidatedCloseTimeAsync(), + Provider = "sponsored", + AssetClass = "currency", + PriceDataSeries = new List + { + new PriceDataWrapper { PriceData = new PriceData { BaseAsset = "XRP", QuoteAsset = "USD", AssetPrice = 740, Scale = 3 } }, + }, + }, sponsee, sponsor); + + await SubmitSponsoredAsync(new OracleDelete { Account = sponsee.ClassicAddress, OracleDocumentID = documentId }, sponsee, sponsor); + } + + [TestMethod] + public async Task Sponsored_CredentialCreate_CredentialDelete() + { + (XrplWallet sponsor, XrplWallet sponsee) = await SetupSponsorshipAsync(); + string credentialType = ToHex("sponsored_credential"); + + // Self-issued: the subject is the issuer, so no CredentialAccept is needed + await SubmitSponsoredAsync(new CredentialCreate { Account = sponsee.ClassicAddress, Subject = sponsee.ClassicAddress, CredentialType = credentialType }, sponsee, sponsor); + await SubmitSponsoredAsync(new CredentialDelete { Account = sponsee.ClassicAddress, Subject = sponsee.ClassicAddress, Issuer = sponsee.ClassicAddress, CredentialType = credentialType }, sponsee, sponsor); + } + + [TestMethod] + public async Task Sponsored_CredentialAccept() + { + (XrplWallet sponsor, XrplWallet sponsee) = await SetupSponsorshipAsync(); + string credentialType = ToHex("sponsored_accept"); + + await SubmitPlainAsync(new CredentialCreate { Account = sponsor.ClassicAddress, Subject = sponsee.ClassicAddress, CredentialType = credentialType }, sponsor, "CredentialCreate"); + await SubmitSponsoredAsync(new CredentialAccept { Account = sponsee.ClassicAddress, Issuer = sponsor.ClassicAddress, CredentialType = credentialType }, sponsee, sponsor); + } + + [TestMethod] + public async Task Sponsored_TicketCreate_SetRegularKey_SignerListSet() + { + (XrplWallet sponsor, XrplWallet sponsee) = await SetupSponsorshipAsync(); + + await SubmitSponsoredAsync(new TicketCreate { Account = sponsee.ClassicAddress, TicketCount = 1 }, sponsee, sponsor); + await SubmitSponsoredAsync(new SetRegularKey { Account = sponsee.ClassicAddress, RegularKey = XrplWallet.Generate().ClassicAddress }, sponsee, sponsor); + await SubmitSponsoredAsync(new SignerListSet + { + Account = sponsee.ClassicAddress, + SignerQuorum = 1, + SignerEntries = new List + { + new SignerEntryWrapper { SignerEntry = new SignerEntry { Account = sponsor.ClassicAddress, SignerWeight = 1 } }, + }, + }, sponsee, sponsor); + } + + [TestMethod] + public async Task Sponsored_EscrowCreate_EscrowFinish_EscrowCancel() + { + (XrplWallet sponsor, XrplWallet sponsee) = await SetupSponsorshipAsync(); + DateTime closeTime = await ValidatedCloseTimeAsync(); + + TransactionSummary finishable = await SubmitSponsoredAsync(new EscrowCreate + { + Account = sponsee.ClassicAddress, + Destination = sponsor.ClassicAddress, + Amount = Xrp(1m), + FinishAfter = closeTime + EscrowFinishMargin, + }, sponsee, sponsor); + TransactionSummary cancellable = await SubmitSponsoredAsync(new EscrowCreate + { + Account = sponsee.ClassicAddress, + Destination = sponsor.ClassicAddress, + Amount = Xrp(1m), + FinishAfter = closeTime + EscrowFinishMargin, + CancelAfter = closeTime + EscrowCancelMargin, + }, sponsee, sponsor); + + await WaitForCloseTimeAsync(closeTime + EscrowCancelMargin); + + await SubmitSponsoredAsync(new EscrowFinish { Account = sponsee.ClassicAddress, Owner = sponsee.ClassicAddress, OfferSequence = finishable.Transaction.Sequence }, sponsee, sponsor); + await SubmitSponsoredAsync(new EscrowCancel { Account = sponsee.ClassicAddress, Owner = sponsee.ClassicAddress, OfferSequence = cancellable.Transaction.Sequence }, sponsee, sponsor); + } + + [TestMethod] + public async Task Sponsored_NFTokenMint_CreateOffer_CancelOffer_Modify_Burn() + { + (XrplWallet sponsor, XrplWallet sponsee) = await SetupSponsorshipAsync(); + + TransactionSummary mint = await SubmitSponsoredAsync(new NFTokenMint + { + Account = sponsee.ClassicAddress, + NFTokenTaxon = 0, + Flags = NFTokenMintFlags.tfTransferable | NFTokenMintFlags.tfMutable, + }, sponsee, sponsor); + string nftokenId = mint.Meta?.NFTokenId; + Assert.IsNotNull(nftokenId, "NFTokenMint must report nftoken_id"); + + TransactionSummary offer = await SubmitSponsoredAsync(new NFTokenCreateOffer + { + Account = sponsee.ClassicAddress, + NFTokenID = nftokenId, + Amount = Xrp(1m), + Flags = NFTokenCreateOfferFlags.tfSellNFToken, + }, sponsee, sponsor); + string offerId = offer.Meta?.OfferID; + Assert.IsNotNull(offerId, "NFTokenCreateOffer must report offer_id"); + + await SubmitSponsoredAsync(new NFTokenCancelOffer { Account = sponsee.ClassicAddress, NFTokenOffers = new[] { offerId } }, sponsee, sponsor); + await SubmitSponsoredAsync(new NFTokenModify { Account = sponsee.ClassicAddress, NFTokenID = nftokenId, URI = ToHex("ipfs://sponsored") }, sponsee, sponsor); + await SubmitSponsoredAsync(new NFTokenBurn { Account = sponsee.ClassicAddress, NFTokenID = nftokenId }, sponsee, sponsor); + } + + [TestMethod] + public async Task Sponsored_NFTokenAcceptOffer() + { + (XrplWallet sponsor, XrplWallet sponsee) = await SetupSponsorshipAsync(); + + TransactionSummary mint = await SubmitPlainAsync(new NFTokenMint { Account = sponsor.ClassicAddress, NFTokenTaxon = 0, Flags = NFTokenMintFlags.tfTransferable }, sponsor, "NFTokenMint"); + TransactionSummary offer = await SubmitPlainAsync(new NFTokenCreateOffer + { + Account = sponsor.ClassicAddress, + NFTokenID = mint.Meta?.NFTokenId, + Amount = Xrp(1m), + Destination = sponsee.ClassicAddress, + Flags = NFTokenCreateOfferFlags.tfSellNFToken, + }, sponsor, "NFTokenCreateOffer"); + + await SubmitSponsoredAsync(new NFTokenAcceptOffer { Account = sponsee.ClassicAddress, NFTokenSellOffer = offer.Meta?.OfferID }, sponsee, sponsor); + } + + [TestMethod] + public async Task Sponsored_MPTokenIssuanceCreate_Set_Destroy() + { + (XrplWallet sponsor, XrplWallet sponsee) = await SetupSponsorshipAsync(); + + TransactionSummary create = await SubmitSponsoredAsync(new MPTokenIssuanceCreate + { + Account = sponsee.ClassicAddress, + Flags = MPTokenIssuanceCreateFlags.tfMPTCanLock, + }, sponsee, sponsor); + string issuanceId = create.Meta?.MptIssuanceId; + Assert.IsNotNull(issuanceId, "MPTokenIssuanceCreate must report mpt_issuance_id"); + + await SubmitSponsoredAsync(new MPTokenIssuanceSet { Account = sponsee.ClassicAddress, MPTokenIssuanceID = issuanceId, Flags = MPTokenIssuanceSetFlags.tfMPTLock }, sponsee, sponsor); + await SubmitSponsoredAsync(new MPTokenIssuanceDestroy { Account = sponsee.ClassicAddress, MPTokenIssuanceID = issuanceId }, sponsee, sponsor); + } + + [TestMethod] + public async Task Sponsored_MPTokenAuthorize() + { + (XrplWallet sponsor, XrplWallet sponsee) = await SetupSponsorshipAsync(); + + TransactionSummary create = await SubmitPlainAsync(new MPTokenIssuanceCreate { Account = sponsor.ClassicAddress }, sponsor, "MPTokenIssuanceCreate"); + await SubmitSponsoredAsync(new MPTokenAuthorize { Account = sponsee.ClassicAddress, MPTokenIssuanceID = create.Meta?.MptIssuanceId }, sponsee, sponsor, + SponsorCoverage.spfSponsorFee | SponsorCoverage.spfSponsorReserve); + } + + [TestMethod] + public async Task Sponsored_CheckCreate_CheckCancel_CheckCash() + { + (XrplWallet sponsor, XrplWallet sponsee) = await SetupSponsorshipAsync(); + + TransactionSummary own = await SubmitSponsoredAsync(new CheckCreate { Account = sponsee.ClassicAddress, Destination = sponsor.ClassicAddress, SendMax = Xrp(1m) }, sponsee, sponsor); + string ownCheck = CreatedIndex(own.Meta, LedgerEntryType.Check); + await SubmitSponsoredAsync(new CheckCancel { Account = sponsee.ClassicAddress, CheckID = ownCheck }, sponsee, sponsor); + + TransactionSummary incoming = await SubmitPlainAsync(new CheckCreate { Account = sponsor.ClassicAddress, Destination = sponsee.ClassicAddress, SendMax = Xrp(1m) }, sponsor, "CheckCreate"); + string incomingCheck = CreatedIndex(incoming.Meta, LedgerEntryType.Check); + await SubmitSponsoredAsync(new CheckCash { Account = sponsee.ClassicAddress, CheckID = incomingCheck, Amount = Xrp(1m) }, sponsee, sponsor); + } + + [TestMethod] + public async Task Sponsored_PaymentChannelCreate_Fund_Claim() + { + (XrplWallet sponsor, XrplWallet sponsee) = await SetupSponsorshipAsync(); + + TransactionSummary open = await SubmitSponsoredAsync(new PaymentChannelCreate + { + Account = sponsee.ClassicAddress, + Destination = sponsor.ClassicAddress, + Amount = "1000000", + SettleDelay = 60, + PublicKey = sponsee.PublicKey, + }, sponsee, sponsor); + string channel = Hashes.HashPaymentChannel(sponsee.ClassicAddress, sponsor.ClassicAddress, (int)open.Transaction.Sequence.Value); + + await SubmitSponsoredAsync(new PaymentChannelFund { Account = sponsee.ClassicAddress, Channel = channel, Amount = "500000" }, sponsee, sponsor); + await SubmitSponsoredAsync(new PaymentChannelClaim { Account = sponsee.ClassicAddress, Channel = channel, Balance = "700000" }, sponsee, sponsor); + } + + [TestMethod] + public async Task Sponsored_DelegateSet() + { + (XrplWallet sponsor, XrplWallet sponsee) = await SetupSponsorshipAsync(); + await SubmitSponsoredAsync(new DelegateSet + { + Account = sponsee.ClassicAddress, + Authorize = sponsor.ClassicAddress, + Permissions = new List { new PermissionWrapper { Permission = new PermissionEntry { PermissionValue = 1 } } }, + }, sponsee, sponsor); + } + + [TestMethod] + public async Task Sponsored_PermissionedDomainSet_Delete() + { + (XrplWallet sponsor, XrplWallet sponsee) = await SetupSponsorshipAsync(); + + TransactionSummary set = await SubmitSponsoredAsync(new PermissionedDomainSet + { + Account = sponsee.ClassicAddress, + AcceptedCredentials = new List + { + new AcceptedCredentialWrapper { Credential = new AcceptedCredential { Issuer = sponsor.ClassicAddress, CredentialType = ToHex("sponsored_domain") } }, + }, + }, sponsee, sponsor); + string domainId = CreatedIndex(set.Meta, LedgerEntryType.PermissionedDomain); + + await SubmitSponsoredAsync(new PermissionedDomainDelete { Account = sponsee.ClassicAddress, DomainID = domainId }, sponsee, sponsor); + } + + [TestMethod] + public async Task Sponsored_Clawback() + { + XrplWallet sponsor = XrplWallet.Generate(); + XrplWallet sponsee = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, sponsor, sponsee); + + // The sponsee issues; the sponsor holds and gets clawed back. The clawback flag + // needs an empty owner directory, so it goes on before the sponsorship exists + await SubmitPlainAsync(new AccountSet { Account = sponsee.ClassicAddress, SetFlag = AccountSetAsfFlags.asfAllowTrustLineClawback }, sponsee, "asfAllowTrustLineClawback"); + await OpenSponsorshipAsync(sponsor, sponsee); + await SubmitPlainAsync(new TrustSet { Account = sponsor.ClassicAddress, LimitAmount = new Currency { CurrencyCode = TestCurrency, Issuer = sponsee.ClassicAddress, Value = "1000" } }, sponsor, "TrustSet"); + await SubmitPlainAsync(new Payment { Account = sponsee.ClassicAddress, Destination = sponsor.ClassicAddress, Amount = new Currency { CurrencyCode = TestCurrency, Issuer = sponsee.ClassicAddress, Value = "100" } }, sponsee, "issue tokens"); + + await SubmitSponsoredAsync(new ClawBack + { + Account = sponsee.ClassicAddress, + Amount = new Currency { CurrencyCode = TestCurrency, Issuer = sponsor.ClassicAddress, Value = "40" }, + }, sponsee, sponsor); + } + + [TestMethod] + public async Task Sponsored_AMMCreate_Deposit_Vote_Bid_Withdraw() + { + (XrplWallet sponsor, XrplWallet sponsee) = await SetupSponsorshipAsync(); + + // The sponsor issues the pool token; the sponsee holds it and runs the pool + await SubmitPlainAsync(new AccountSet { Account = sponsor.ClassicAddress, SetFlag = AccountSetAsfFlags.asfDefaultRipple }, sponsor, "asfDefaultRipple"); + await SubmitPlainAsync(new TrustSet { Account = sponsee.ClassicAddress, LimitAmount = new Currency { CurrencyCode = TestCurrency, Issuer = sponsor.ClassicAddress, Value = "1000000" } }, sponsee, "TrustSet"); + await SubmitPlainAsync(new Payment { Account = sponsor.ClassicAddress, Destination = sponsee.ClassicAddress, Amount = new Currency { CurrencyCode = TestCurrency, Issuer = sponsor.ClassicAddress, Value = "10000" } }, sponsor, "issue tokens"); + + IssuedCurrency token = new IssuedCurrency { Currency = TestCurrency, Issuer = sponsor.ClassicAddress }; + IssuedCurrency xrp = new IssuedCurrency { Currency = "XRP" }; + Currency tokens(string value) => new Currency { CurrencyCode = TestCurrency, Issuer = sponsor.ClassicAddress, Value = value }; + + await SubmitSponsoredAsync(new AMMCreate { Account = sponsee.ClassicAddress, Amount = tokens("1000"), Amount2 = Xrp(10m), TradingFee = 500 }, sponsee, sponsor); + await SubmitSponsoredAsync(new AMMDeposit { Account = sponsee.ClassicAddress, Asset = token, Asset2 = xrp, Amount = tokens("100"), Flags = AMMDepositFlags.tfSingleAsset }, sponsee, sponsor); + await SubmitSponsoredAsync(new AMMVote { Account = sponsee.ClassicAddress, Asset = token, Asset2 = xrp, TradingFee = 100 }, sponsee, sponsor); + await SubmitSponsoredAsync(new AMMBid { Account = sponsee.ClassicAddress, Asset = token, Asset2 = xrp }, sponsee, sponsor); + await SubmitSponsoredAsync(new AMMWithdraw { Account = sponsee.ClassicAddress, Asset = token, Asset2 = xrp, Amount = tokens("50"), Flags = AMMWithdrawFlags.tfSingleAsset }, sponsee, sponsor); + } + + [TestMethod] + public async Task Sponsored_XChain_CreateBridge_Modify_ClaimID_Commit_AccountCreateCommit() + { + (XrplWallet sponsor, XrplWallet sponsee) = await SetupSponsorshipAsync(); + + // The sponsee is the door of its own bridge for the bridge-owner transactions... + XChainBridgeModel ownBridge = new XChainBridgeModel + { + LockingChainDoor = sponsee.ClassicAddress, + LockingChainIssue = new IssuedCurrency { Currency = "XRP" }, + IssuingChainDoor = GenesisAccount, + IssuingChainIssue = new IssuedCurrency { Currency = "XRP" }, + }; + await SubmitSponsoredAsync(new XChainCreateBridge { Account = sponsee.ClassicAddress, XChainBridge = ownBridge, SignatureReward = Drops("100") }, sponsee, sponsor); + await SubmitSponsoredAsync(new XChainModifyBridge { Account = sponsee.ClassicAddress, XChainBridge = ownBridge, SignatureReward = Drops("200") }, sponsee, sponsor); + + // ...and a user of the sponsor's bridge for the transfer-side transactions + XChainBridgeModel sponsorBridge = new XChainBridgeModel + { + LockingChainDoor = sponsor.ClassicAddress, + LockingChainIssue = new IssuedCurrency { Currency = "XRP" }, + IssuingChainDoor = GenesisAccount, + IssuingChainIssue = new IssuedCurrency { Currency = "XRP" }, + }; + await SubmitPlainAsync(new XChainCreateBridge { Account = sponsor.ClassicAddress, XChainBridge = sponsorBridge, SignatureReward = Drops("100"), MinAccountCreateAmount = Drops("10000000") }, sponsor, "XChainCreateBridge"); + + await SubmitSponsoredAsync(new XChainCreateClaimID { Account = sponsee.ClassicAddress, XChainBridge = sponsorBridge, SignatureReward = Drops("100"), OtherChainSource = sponsee.ClassicAddress }, sponsee, sponsor); + await SubmitSponsoredAsync(new XChainCommit { Account = sponsee.ClassicAddress, XChainBridge = sponsorBridge, XChainClaimID = "1", Amount = Drops("1000000"), OtherChainDestination = XrplWallet.Generate().ClassicAddress }, sponsee, sponsor); + await SubmitSponsoredAsync(new XChainAccountCreateCommit { Account = sponsee.ClassicAddress, XChainBridge = sponsorBridge, Destination = XrplWallet.Generate().ClassicAddress, Amount = Drops("20000000"), SignatureReward = Drops("100") }, sponsee, sponsor); + } + + /// + /// A sponsored Payment funding a brand-new account with tfSponsorCreatedAccount: the + /// sponsor covers the new account's reserve. + /// + [TestMethod] + public async Task Sponsored_Payment_SponsorCreatedAccount() + { + (XrplWallet sponsor, XrplWallet sponsee) = await SetupSponsorshipAsync(); + XrplWallet created = XrplWallet.Generate(); + + await SubmitSponsoredAsync(new Payment + { + Account = sponsee.ClassicAddress, + Destination = created.ClassicAddress, + Amount = Xrp(2m), + Flags = PaymentFlags.tfSponsorCreatedAccount, + }, sponsee, sponsor, SponsorCoverage.spfSponsorFee | SponsorCoverage.spfSponsorReserve); + + AccountInfo info = await client.AccountInfo(new AccountInfoRequest(created.ClassicAddress)).Typed(); + Assert.IsNotNull(info.AccountData, "the sponsored payment must create the destination account"); + } + + /// + /// A sponsored transaction that ends in a tec is still validated with the sponsor on it. + /// + [TestMethod] + public async Task Sponsored_LedgerStateFix_RecordsTec() + { + (XrplWallet sponsor, XrplWallet sponsee) = await SetupSponsorshipAsync(); + + LedgerStateFix tx = new LedgerStateFix + { + Account = sponsee.ClassicAddress, + LedgerFixType = 1, + Owner = sponsee.ClassicAddress, + Sponsor = sponsor.ClassicAddress, + SponsorFlags = SponsorCoverage.spfSponsorFee, + }; + + string result; + try + { + TransactionSummary res = await client.SubmitAndWaitSponsored(tx, sponsee, sponsor); + result = res.Meta?.TransactionResult; + } + catch (TransactionFailedException ex) + { + result = ex.EngineResult ?? ex.Message; + } + + Assert.IsTrue(result is not null && (result.Contains("tesSUCCESS") || result.Contains("tecFAILED_PROCESSING")), + $"the sponsored LedgerStateFix must reach a validated ledger, got {result}"); + } + + private static string CreatedIndex(Meta meta, LedgerEntryType type) => + meta?.AffectedNodes? + .Select(n => n.CreatedNode) + .FirstOrDefault(c => c is { } && c.LedgerEntryType == type)?.LedgerIndex + ?? throw new AssertFailedException($"metadata carries no created {type} node"); +} From 3cf14bf5701a436cd3dd8e16667741e60a6b4b82 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 08:31:55 -0300 Subject: [PATCH 04/26] feat(loan): multisig counterparty co-signature for LoanSet 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 and pre-checks the quorum by weight; SignatureComposer.ComposeSignatures takes counterpartySignerAccounts for the offline case, with LoanSigningHelper.CombineLoanSignatures as the LoanSet-shaped entry. The fee has to cover the signers (LoanSet::calculateBaseFee charges one base fee per entry), so the flow autofills with signersCount before anyone signs. Pinned on the standalone node: a 2-of-2 borrower, ledger-routed and offline composition, and the below-quorum refusal. --- DocFx/LendingProtocol-Guide.md | 31 ++++ DocFx/LendingProtocol-Guide.ru.md | 31 ++++ .../transactions/TestILoanMultisig.cs | 147 +++++++++++++++ .../Wallet/TestULoanCounterpartyMultisign.cs | 171 ++++++++++++++++++ Xrpl/Sugar/ComposeSugar.cs | 36 +++- Xrpl/Wallet/LoanSigningHelper.cs | 40 ++++ Xrpl/Wallet/SignatureComposer.cs | 165 +++++++++++------ 7 files changed, 554 insertions(+), 67 deletions(-) create mode 100644 Tests/Xrpl.Tests/Integration/transactions/TestILoanMultisig.cs create mode 100644 Tests/Xrpl.Tests/Wallet/TestULoanCounterpartyMultisign.cs diff --git a/DocFx/LendingProtocol-Guide.md b/DocFx/LendingProtocol-Guide.md index f3db8a8f..8cf5001d 100644 --- a/DocFx/LendingProtocol-Guide.md +++ b/DocFx/LendingProtocol-Guide.md @@ -326,6 +326,37 @@ await client.SubmitRequest(fullySigned.TxBlob); > **Important:** Do not use `brokerWallet.Sign()` on a partially signed LoanSet blob — it does not handle `CounterpartySignature` correctly. Always use `LoanSigningHelper.BrokerSign()` for the V3 pattern. +### Multisig Borrower (Counterparty with a SignerList) + +`CounterpartySignature` takes the multisig form when the borrower is a multisig account: an empty `SigningPubKey` and a `Signers` array that the node checks against the **counterparty's** SignerList, over the same multisign preimage as `tx.Signers`. Each signer of the borrower's list signs with the standard multisign call; the composer places the entries. + +```csharp +// The fee covers one base fee per counterparty signer (rippled LoanSet::calculateBaseFee), +// so autofill with the signer count before anyone signs +Dictionary autofilled = await client.Autofill(loanTx.ToDictionary(), signersCount: 2); +JsonObject prepared = LoanSigningHelper.PrepareForSigning( + JsonNode.Parse(JsonSerializer.Serialize(autofilled, XrplJsonOptions.Default)).AsObject(), brokerWallet); +var preparedDict = JsonSerializer.Deserialize>(prepared.ToJsonString(), XrplJsonOptions.Default); + +// Broker: single main signature. Borrower's signers: portable multisign entries +SignatureResult brokerPart = brokerWallet.Sign(new Dictionary(preparedDict)); +SignatureResult part1 = signer1.Sign(new Dictionary(preparedDict), multisign: true); +SignatureResult part2 = signer2.Sign(new Dictionary(preparedDict), multisign: true); + +// Ledger-driven: the composer looks the signers up in the Counterparty's SignerList +// and pre-checks the quorum by weight +SignatureResult composed = await client.ComposeSignatures(new[] { brokerPart.TxBlob, part1.TxBlob, part2.TxBlob }); + +// Offline: name the borrower's signers yourself +SignatureResult offline = LoanSigningHelper.CombineLoanSignatures( + new[] { brokerPart.TxBlob, part1.TxBlob, part2.TxBlob }, + new[] { signer1.ClassicAddress, signer2.ClassicAddress }); + +await client.SubmitRequest(composed.TxBlob); +``` + +A signer that appears in more than one SignerList (the broker's, the sponsor's, the borrower's) is an error for the ledger-driven composer; compose offline with explicit sides in that case. + ### Key Points - Both parties sign the **same** preimage (the transaction serialized for signing, without any signature fields) diff --git a/DocFx/LendingProtocol-Guide.ru.md b/DocFx/LendingProtocol-Guide.ru.md index d808c471..bdb9a333 100644 --- a/DocFx/LendingProtocol-Guide.ru.md +++ b/DocFx/LendingProtocol-Guide.ru.md @@ -326,6 +326,37 @@ await client.SubmitRequest(fullySigned.TxBlob); > **Важно:** Не используйте `brokerWallet.Sign()` для частично подписанного LoanSet blob — он не обрабатывает `CounterpartySignature` корректно. Всегда используйте `LoanSigningHelper.BrokerSign()` для паттерна V3. +### Заёмщик с мультиподписью (Counterparty со SignerList) + +Когда заёмщик — мультиподписной аккаунт, `CounterpartySignature` принимает мультиподписную форму: пустой `SigningPubKey` и массив `Signers`, который нода сверяет со SignerList **контрагента** по тому же прообразу мультиподписи, что и `tx.Signers`. Каждый подписант из списка заёмщика подписывает стандартным вызовом мультиподписи; композитор расставляет записи по секциям. + +```csharp +// Комиссия покрывает по одной базовой комиссии на каждого подписанта контрагента +// (rippled LoanSet::calculateBaseFee), поэтому autofill выполняется с числом подписантов до подписания +Dictionary autofilled = await client.Autofill(loanTx.ToDictionary(), signersCount: 2); +JsonObject prepared = LoanSigningHelper.PrepareForSigning( + JsonNode.Parse(JsonSerializer.Serialize(autofilled, XrplJsonOptions.Default)).AsObject(), brokerWallet); +var preparedDict = JsonSerializer.Deserialize>(prepared.ToJsonString(), XrplJsonOptions.Default); + +// Брокер: одиночная основная подпись. Подписанты заёмщика: переносимые записи мультиподписи +SignatureResult brokerPart = brokerWallet.Sign(new Dictionary(preparedDict)); +SignatureResult part1 = signer1.Sign(new Dictionary(preparedDict), multisign: true); +SignatureResult part2 = signer2.Sign(new Dictionary(preparedDict), multisign: true); + +// По данным реестра: композитор находит подписантов в SignerList контрагента +// и заранее проверяет кворум по весам +SignatureResult composed = await client.ComposeSignatures(new[] { brokerPart.TxBlob, part1.TxBlob, part2.TxBlob }); + +// Оффлайн: подписанты заёмщика перечисляются явно +SignatureResult offline = LoanSigningHelper.CombineLoanSignatures( + new[] { brokerPart.TxBlob, part1.TxBlob, part2.TxBlob }, + new[] { signer1.ClassicAddress, signer2.ClassicAddress }); + +await client.SubmitRequest(composed.TxBlob); +``` + +Подписант, входящий более чем в один SignerList (брокера, спонсора, заёмщика), для композитора по данным реестра является ошибкой; в таком случае необходимо собирать подписи оффлайн с явным указанием сторон. + ### Ключевые моменты - Обе стороны подписывают **одинаковый** прообраз (транзакция, сериализованная для подписи, без полей подписей) diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestILoanMultisig.cs b/Tests/Xrpl.Tests/Integration/transactions/TestILoanMultisig.cs new file mode 100644 index 00000000..e1d8ea77 --- /dev/null +++ b/Tests/Xrpl.Tests/Integration/transactions/TestILoanMultisig.cs @@ -0,0 +1,147 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.BinaryCodec; +using Xrpl.Client; +using Xrpl.Client.Json; +using Xrpl.Models.Common; +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; +using Xrpl.Models.Transactions; +using Xrpl.Sugar; +using Xrpl.Wallet; + +namespace XrplTests.Xrpl.ClientLib.Integration; + +/// +/// XLS-66 LoanSet where the borrower (Counterparty) is a multisig account: its +/// SignerList members sign with the standard multisign call and the composer routes +/// the entries into CounterpartySignature.Signers, which the node verifies against +/// the borrower's SignerList. +/// +[TestClass] +[TestCategory("Loan")] +public class TestILoanMultisig : TestILoanBase +{ + private static IXrplClient client; + protected override IXrplClient GetClient() => client; + + [ClassInitialize] + public static async Task ClassInitializeAsync(TestContext testContext) + { + client = await CreateStandaloneClient(); + } + + [ClassCleanup] + public static void ClassCleanup() => client?.Dispose(); + + private sealed record MultisigLoan(XrplWallet Broker, XrplWallet Signer1, XrplWallet Signer2, Dictionary Prepared); + + /// + /// Funds broker, borrower and two signers, gives the borrower a 2-of-2 SignerList, + /// and prepares a LoanSet whose fee already covers the two counterparty signers. + /// + private static async Task SetupAsync() + { + XrplWallet broker = XrplWallet.Generate(); + XrplWallet borrower = XrplWallet.Generate(); + XrplWallet signer1 = XrplWallet.Generate(); + XrplWallet signer2 = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, broker, borrower, signer1, signer2); + + string brokerId = await CreateBroker(client, broker); + + SignerListSet signerList = new SignerListSet + { + Account = borrower.ClassicAddress, + SignerQuorum = 2, + SignerEntries = new List + { + new SignerEntryWrapper { SignerEntry = new SignerEntry { Account = signer1.ClassicAddress, SignerWeight = 1 } }, + new SignerEntryWrapper { SignerEntry = new SignerEntry { Account = signer2.ClassicAddress, SignerWeight = 1 } }, + }, + }; + signerList = await client.Autofill(signerList); + ValidateResult(await client.SubmitAndWait(signerList, borrower, true)); + + LoanSet loanTx = new LoanSet + { + Account = broker.ClassicAddress, + LoanBrokerID = brokerId, + Counterparty = borrower.ClassicAddress, + PrincipalRequested = "10000000", + }; + // rippled LoanSet::calculateBaseFee charges one base fee per counterparty signer + Dictionary autofilled = await client.Autofill(loanTx.ToDictionary(), signersCount: 2); + JsonObject prepared = LoanSigningHelper.PrepareForSigning( + JsonNode.Parse(JsonSerializer.Serialize(autofilled, XrplJsonOptions.Default)).AsObject(), broker); + Dictionary preparedDict = JsonSerializer.Deserialize>(prepared.ToJsonString(), XrplJsonOptions.Default); + + return new MultisigLoan(broker, signer1, signer2, preparedDict); + } + + private static void AssertCounterpartyMultisig(SignatureResult composed) + { + JsonObject decoded = XrplBinaryCodec.Decode(composed.TxBlob).AsObject(); + Assert.IsNull(decoded["Signers"], "the broker signs single, nothing may land in the main Signers"); + JsonObject counterparty = decoded["CounterpartySignature"].AsObject(); + Assert.AreEqual("", counterparty["SigningPubKey"].GetValue()); + Assert.AreEqual(2, counterparty["Signers"].AsArray().Count, "both borrower signers must be present"); + } + + [TestMethod] + public async Task TestLoanSet_MultisigCounterparty_LedgerRoutedCompose() + { + MultisigLoan loan = await SetupAsync(); + + string brokerPart = loan.Broker.Sign(new Dictionary(loan.Prepared)).TxBlob; + string part1 = loan.Signer1.Sign(new Dictionary(loan.Prepared), multisign: true).TxBlob; + string part2 = loan.Signer2.Sign(new Dictionary(loan.Prepared), multisign: true).TxBlob; + + // The composer looks the signers up in the Counterparty's SignerList + SignatureResult composed = await client.ComposeSignatures(new[] { brokerPart, part1, part2 }); + AssertCounterpartyMultisig(composed); + + TransactionSummary result = await SubmitSignedLoanSet(client, composed.TxBlob); + ValidateResult(result); + } + + [TestMethod] + public async Task TestLoanSet_MultisigCounterparty_OfflineCompose() + { + MultisigLoan loan = await SetupAsync(); + + string brokerPart = loan.Broker.Sign(new Dictionary(loan.Prepared)).TxBlob; + string part1 = loan.Signer1.Sign(new Dictionary(loan.Prepared), multisign: true).TxBlob; + string part2 = loan.Signer2.Sign(new Dictionary(loan.Prepared), multisign: true).TxBlob; + + SignatureResult composed = LoanSigningHelper.CombineLoanSignatures( + new[] { brokerPart, part1, part2 }, + new[] { loan.Signer1.ClassicAddress, loan.Signer2.ClassicAddress }); + AssertCounterpartyMultisig(composed); + + TransactionSummary result = await SubmitSignedLoanSet(client, composed.TxBlob); + ValidateResult(result); + } + + /// + /// One signer of a 2-of-2 list is not a quorum: the ledger-driven composer refuses + /// before the node would answer tefBAD_QUORUM. + /// + [TestMethod] + public async Task TestLoanSet_MultisigCounterparty_BelowQuorum_ComposeFails() + { + MultisigLoan loan = await SetupAsync(); + + string brokerPart = loan.Broker.Sign(new Dictionary(loan.Prepared)).TxBlob; + string part1 = loan.Signer1.Sign(new Dictionary(loan.Prepared), multisign: true).TxBlob; + + global::Xrpl.Client.Exceptions.ValidationException ex = await Assert.ThrowsExactlyAsync( + () => client.ComposeSignatures(new[] { brokerPart, part1 })); + StringAssert.Contains(ex.Message, "Counterparty"); + } +} diff --git a/Tests/Xrpl.Tests/Wallet/TestULoanCounterpartyMultisign.cs b/Tests/Xrpl.Tests/Wallet/TestULoanCounterpartyMultisign.cs new file mode 100644 index 00000000..63b32e61 --- /dev/null +++ b/Tests/Xrpl.Tests/Wallet/TestULoanCounterpartyMultisign.cs @@ -0,0 +1,171 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.BinaryCodec; +using Xrpl.Client.Exceptions; +using Xrpl.Client.Json; +using Xrpl.Keypairs; +using Xrpl.Wallet; + +namespace XrplTests.Xrpl.Wallet +{ + /// + /// XLS-66 LoanSet with a multisig borrower: the borrower's signers produce portable + /// Signer entries, and the composer places them under CounterpartySignature.Signers. + /// + [TestClass] + public class TestULoanCounterpartyMultisign + { + private static readonly XrplWallet Broker = XrplWallet.Generate(); + private static readonly XrplWallet Borrower = XrplWallet.Generate(); + private static readonly XrplWallet Signer1 = XrplWallet.Generate(); + private static readonly XrplWallet Signer2 = XrplWallet.Generate("secp256k1"); + private static readonly XrplWallet Stranger = XrplWallet.Generate(); + + private const string BrokerId = "1111111111111111111111111111111111111111111111111111111111111111"; + + private static Dictionary Prepared() => new Dictionary + { + ["TransactionType"] = "LoanSet", + ["Account"] = Broker.ClassicAddress, + ["LoanBrokerID"] = BrokerId, + ["Counterparty"] = Borrower.ClassicAddress, + ["PrincipalRequested"] = "10000000", + ["Fee"] = "360", + ["Sequence"] = 5u, + ["SigningPubKey"] = Broker.PublicKey, + }; + + private static Dictionary ToDict(JsonObject json) => + JsonSerializer.Deserialize>(json.ToJsonString(), XrplJsonOptions.Default); + + [TestMethod] + public void TestUCombine_MultisigBorrower_EntriesLandInCounterpartySignature() + { + Dictionary prepared = Prepared(); + SignatureResult brokerPart = Broker.Sign(new Dictionary(prepared)); + SignatureResult part1 = Signer1.Sign(new Dictionary(prepared), multisign: true); + SignatureResult part2 = Signer2.Sign(new Dictionary(prepared), multisign: true); + + SignatureResult composed = LoanSigningHelper.CombineLoanSignatures( + new[] { brokerPart.TxBlob, part1.TxBlob, part2.TxBlob }, + new[] { Signer1.ClassicAddress, Signer2.ClassicAddress }); + + JsonObject decoded = XrplBinaryCodec.Decode(composed.TxBlob).AsObject(); + Assert.AreEqual(Broker.PublicKey, decoded["SigningPubKey"].GetValue(), "the broker keeps the single main signature"); + Assert.IsNotNull(decoded["TxnSignature"], "the broker's TxnSignature must be present"); + Assert.IsNull(decoded["Signers"], "borrower-side entries must not land in the main Signers"); + + JsonObject counterparty = decoded["CounterpartySignature"].AsObject(); + Assert.AreEqual("", counterparty["SigningPubKey"].GetValue(), "the multisig form carries an empty SigningPubKey"); + Assert.IsNull(counterparty["TxnSignature"]); + JsonArray signers = counterparty["Signers"].AsArray(); + Assert.AreEqual(2, signers.Count, "both borrower signers must be present"); + + // Each entry verifies over the multisign preimage of the composed transaction: + // the outer tx without signature fields, with the broker's SigningPubKey, plus the signer's account + JsonObject forSigning = decoded.WithoutFields("TxnSignature", "Signers", "CounterpartySignature"); + foreach (JsonNode entry in signers) + { + JsonObject signer = entry["Signer"].AsObject(); + string account = signer["Account"].GetValue(); + byte[] preimage = global::Xrpl.AddressCodec.Utils.FromHex(XrplBinaryCodec.EncodeForMultiSigning(forSigning, account)); + Assert.IsTrue( + XrplKeypairs.Verify(preimage, signer["TxnSignature"].GetValue(), signer["SigningPubKey"].GetValue()), + $"the entry of {account} must verify over the multisign preimage"); + } + + // Sorted by account id bytes, as rippled requires + List order = signers.Select(e => e["Signer"]["Account"].GetValue()).ToList(); + List expected = order + .OrderBy(a => global::Xrpl.AddressCodec.XrplCodec.DecodeAccountID(a), new ByteArrayComparer()) + .ToList(); + CollectionAssert.AreEqual(expected, order, "Signers must be sorted by account id"); + } + + [TestMethod] + public void TestUCompose_UnlistedSigner_StaysOnTheBrokerSide() + { + Dictionary prepared = Prepared(); + prepared["SigningPubKey"] = ""; + SignatureResult brokerSigner = Stranger.Sign(new Dictionary(prepared), multisign: true); + SignatureResult borrowerSigner = Signer1.Sign(new Dictionary(prepared), multisign: true); + + SignatureResult composed = SignatureComposer.ComposeSignatures( + new[] { brokerSigner.TxBlob, borrowerSigner.TxBlob }, + counterpartySignerAccounts: new[] { Signer1.ClassicAddress }); + + JsonObject decoded = XrplBinaryCodec.Decode(composed.TxBlob).AsObject(); + Assert.AreEqual(1, decoded["Signers"].AsArray().Count, "the unlisted signer is a broker-side multisigner"); + Assert.AreEqual(Stranger.ClassicAddress, decoded["Signers"][0]["Signer"]["Account"].GetValue()); + Assert.AreEqual(1, decoded["CounterpartySignature"]["Signers"].AsArray().Count); + Assert.AreEqual(Signer1.ClassicAddress, decoded["CounterpartySignature"]["Signers"][0]["Signer"]["Account"].GetValue()); + } + + [TestMethod] + public void TestUCompose_SignerOnSponsorAndCounterpartySides_Throws() + { + Dictionary prepared = Prepared(); + SignatureResult brokerPart = Broker.Sign(new Dictionary(prepared)); + SignatureResult part1 = Signer1.Sign(new Dictionary(prepared), multisign: true); + + ValidationException ex = Assert.ThrowsExactly(() => + SignatureComposer.ComposeSignatures( + new[] { brokerPart.TxBlob, part1.TxBlob }, + new[] { Signer1.ClassicAddress }, + new[] { Signer1.ClassicAddress })); + StringAssert.Contains(ex.Message, "Ambiguous signer role"); + } + + [TestMethod] + public void TestUCombine_SingleAndMultisigCounterparty_Throws() + { + Dictionary prepared = Prepared(); + SignatureResult brokerPart = Broker.Sign(new Dictionary(prepared)); + SignatureResult single = Borrower.SignAsLoanCounterparty(new Dictionary(prepared)); + SignatureResult part1 = Signer1.Sign(new Dictionary(prepared), multisign: true); + + ValidationException ex = Assert.ThrowsExactly(() => + LoanSigningHelper.CombineLoanSignatures( + new[] { brokerPart.TxBlob, single.TxBlob, part1.TxBlob }, + new[] { Signer1.ClassicAddress })); + StringAssert.Contains(ex.Message, "one or the other"); + } + + [TestMethod] + public void TestUCombine_NotALoanSet_Throws() + { + Dictionary payment = new Dictionary + { + ["TransactionType"] = "Payment", + ["Account"] = Broker.ClassicAddress, + ["Destination"] = Borrower.ClassicAddress, + ["Amount"] = "1000000", + ["Fee"] = "12", + ["Sequence"] = 5u, + }; + SignatureResult part = Broker.Sign(payment); + + ValidationException ex = Assert.ThrowsExactly(() => + LoanSigningHelper.CombineLoanSignatures(new[] { part.TxBlob }, new[] { Signer1.ClassicAddress })); + StringAssert.Contains(ex.Message, "LoanSet"); + } + + private sealed class ByteArrayComparer : IComparer + { + public int Compare(byte[] x, byte[] y) + { + for (int i = 0; i < x.Length && i < y.Length; i++) + { + int c = x[i].CompareTo(y[i]); + if (c != 0) return c; + } + return x.Length.CompareTo(y.Length); + } + } + } +} diff --git a/Xrpl/Sugar/ComposeSugar.cs b/Xrpl/Sugar/ComposeSugar.cs index a3cf246b..91cae2db 100644 --- a/Xrpl/Sugar/ComposeSugar.cs +++ b/Xrpl/Sugar/ComposeSugar.cs @@ -18,17 +18,18 @@ namespace Xrpl.Sugar { /// /// Ledger-driven signature composition (#43): routes portable multisig - /// Signer entries into tx.Signers or SponsorSignature.Signers by looking - /// up the SignerLists of the transaction's Account and Sponsor. + /// Signer entries into tx.Signers, SponsorSignature.Signers or + /// CounterpartySignature.Signers by looking up the SignerLists of the + /// transaction's Account, Sponsor and (for LoanSet) Counterparty. /// public static class ComposeSugar { /// /// Composes a fully signed transaction from partially signed blobs, /// resolving each Signer entry's section from the ledger SignerLists. - /// A signer present in both lists is an explicit error — use the + /// A signer present in more than one list is an explicit error — use the /// offline overload - /// with explicit sponsor signers for that case. + /// with explicit side signers for that case. /// public static async Task ComposeSignatures( this IXrplClient client, @@ -43,6 +44,11 @@ public static async Task ComposeSignatures( string account = first["Account"]?.GetValue() ?? throw new ValidationException("Transaction is missing the Account field."); string? sponsor = first["Sponsor"]?.GetValue(); + // XLS-66: the LoanSet borrower co-signs through CounterpartySignature, with its own + // SignerList when it is a multisig account + string? counterparty = string.Equals(first["TransactionType"]?.GetValue(), "LoanSet", StringComparison.OrdinalIgnoreCase) + ? first["Counterparty"]?.GetValue() + : null; // Which signer accounts actually appear across the parts? HashSet seenSigners = new HashSet(StringComparer.Ordinal); @@ -51,39 +57,51 @@ public static async Task ComposeSignatures( JsonObject part = XrplBinaryCodec.Decode(blob).AsObject(); CollectSignerAccounts(part["Signers"] as JsonArray, seenSigners); CollectSignerAccounts(part["SponsorSignature"]?["Signers"] as JsonArray, seenSigners); + CollectSignerAccounts(part["CounterpartySignature"]?["Signers"] as JsonArray, seenSigners); } HashSet sponsorSide = new HashSet(StringComparer.Ordinal); + HashSet counterpartySide = new HashSet(StringComparer.Ordinal); LOSignerList? accountSignerList = null; LOSignerList? sponsorSignerList = null; + LOSignerList? counterpartySignerList = null; if (seenSigners.Count > 0) { accountSignerList = await GetSignerList(client, account, cancellationToken).ConfigureAwait(false); sponsorSignerList = sponsor is null ? null : await GetSignerList(client, sponsor, cancellationToken).ConfigureAwait(false); + counterpartySignerList = counterparty is null + ? null + : await GetSignerList(client, counterparty, cancellationToken).ConfigureAwait(false); HashSet accountList = ToAccountSet(accountSignerList); HashSet sponsorList = ToAccountSet(sponsorSignerList); + HashSet counterpartyList = ToAccountSet(counterpartySignerList); foreach (string signer in seenSigners) { bool inAccount = accountList.Contains(signer); bool inSponsor = sponsorList.Contains(signer); - if (inAccount && inSponsor) - throw new ValidationException($"Ambiguous signer role for {signer}: present in both the Account's and the Sponsor's SignerList. Compose offline with explicit sponsor signers."); - if (!inAccount && !inSponsor) - throw new ValidationException($"Unknown signer {signer}: not in the Account's SignerList{(sponsor is null ? "" : " or the Sponsor's SignerList")}."); + bool inCounterparty = counterpartyList.Contains(signer); + int roles = (inAccount ? 1 : 0) + (inSponsor ? 1 : 0) + (inCounterparty ? 1 : 0); + if (roles > 1) + throw new ValidationException($"Ambiguous signer role for {signer}: present in more than one of the Account's, the Sponsor's and the Counterparty's SignerLists. Compose offline with explicit side signers."); + if (roles == 0) + throw new ValidationException($"Unknown signer {signer}: not in the Account's SignerList{(sponsor is null ? "" : ", the Sponsor's SignerList")}{(counterparty is null ? "" : ", the Counterparty's SignerList")}."); if (inSponsor) sponsorSide.Add(signer); + if (inCounterparty) + counterpartySide.Add(signer); } } - SignatureResult composed = SignatureComposer.ComposeSignatures(parts, sponsorSide); + SignatureResult composed = SignatureComposer.ComposeSignatures(parts, sponsorSide, counterpartySide); // Quorum pre-check by weights, for each side using the multisig form JsonObject result = XrplBinaryCodec.Decode(composed.TxBlob).AsObject(); ValidateQuorum(accountSignerList, result["Signers"] as JsonArray, "Account"); ValidateQuorum(sponsorSignerList, result["SponsorSignature"]?["Signers"] as JsonArray, "Sponsor"); + ValidateQuorum(counterpartySignerList, result["CounterpartySignature"]?["Signers"] as JsonArray, "Counterparty"); return composed; } diff --git a/Xrpl/Wallet/LoanSigningHelper.cs b/Xrpl/Wallet/LoanSigningHelper.cs index fef671be..7ed3c5f0 100644 --- a/Xrpl/Wallet/LoanSigningHelper.cs +++ b/Xrpl/Wallet/LoanSigningHelper.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; @@ -42,6 +44,24 @@ namespace Xrpl.Wallet /// var final = LoanSigningHelper.BrokerSign(withCounterparty.TxBlob, brokerWallet); /// await client.SubmitRequest(final.TxBlob); /// + /// + /// Multisig borrower (Counterparty with a SignerList): each signer of the + /// borrower's list produces a portable Signer entry with the standard multisign + /// call, and the composer places them under CounterpartySignature.Signers + /// (rippled verifies them against the counterparty's SignerList over the same + /// multisign preimage as tx.Signers). Autofill with the signer count so the + /// fee covers them (rippled LoanSet::calculateBaseFee charges one base fee + /// per counterparty signer): + /// + /// var prepared = LoanSigningHelper.PrepareForSigning(await client.Autofill(loanTx.ToDictionary(), signersCount: 2), brokerWallet); + /// var brokerPart = brokerWallet.Sign(prepared); + /// var part1 = signer1.Sign(prepared, multisign: true); + /// var part2 = signer2.Sign(prepared, multisign: true); + /// // ledger-driven routing (looks up the Counterparty's SignerList): + /// var composed = await client.ComposeSignatures(new[] { brokerPart.TxBlob, part1.TxBlob, part2.TxBlob }); + /// // or offline, naming the borrower's signers: + /// var offline = LoanSigningHelper.CombineLoanSignatures(new[] { brokerPart.TxBlob, part1.TxBlob, part2.TxBlob }, new[] { signer1.ClassicAddress, signer2.ClassicAddress }); + /// /// public static class LoanSigningHelper { @@ -133,6 +153,26 @@ public static SignatureResult CombineLoanSignatures( return CoSigningEngine.Combine(brokerSignedBlob, counterpartySignedBlob, "CounterpartySignature", "Counterparty"); } + /// + /// Combines a broker part with the portable Signer entries of a multisig + /// borrower: entries from land in + /// CounterpartySignature.Signers, any other entry in tx.Signers + /// (a multisig broker). Offline; for ledger-driven routing use + /// IXrplClient.ComposeSignatures. + /// + /// The broker's blob and the borrower-side multisign parts. + /// Accounts of the borrower's SignerList. + public static SignatureResult CombineLoanSignatures( + IEnumerable partBlobs, + IReadOnlyCollection counterpartySignerAccounts) + { + List parts = partBlobs?.ToList() + ?? throw new ValidationException("At least one partially signed blob is required."); + foreach (string blob in parts) + RequireLoanSet(blob, "Part"); + return SignatureComposer.ComposeSignatures(parts, null, counterpartySignerAccounts); + } + /// /// V3 — Broker signs a partially signed LoanSet blob (one that already has CounterpartySignature). /// Decodes the blob, strips CounterpartySignature to compute the correct preimage, diff --git a/Xrpl/Wallet/SignatureComposer.cs b/Xrpl/Wallet/SignatureComposer.cs index 9a5e8853..6e641fb4 100644 --- a/Xrpl/Wallet/SignatureComposer.cs +++ b/Xrpl/Wallet/SignatureComposer.cs @@ -14,40 +14,72 @@ namespace Xrpl.Wallet /// /// Composes a fully signed transaction from partially signed blobs (#43). /// Devices sign with whatever keys they hold — single main signature, - /// sponsor co-signature, or portable multisig Signer entries — and the - /// composer routes everything into the right sections. Signer entries are - /// section-agnostic by protocol (identical preimage for tx.Signers and - /// SponsorSignature.Signers, see rippled STTx::checkMultiSign), so only - /// the composer needs to know which signer belongs to which side. + /// sponsor or counterparty co-signature, or portable multisig Signer + /// entries — and the composer routes everything into the right sections. + /// Signer entries are section-agnostic by protocol (identical preimage for + /// tx.Signers, SponsorSignature.Signers and CounterpartySignature.Signers, + /// see rippled STTx::checkMultiSign), so only the composer needs to know + /// which signer belongs to which side. /// public static class SignatureComposer { + private const string SponsorField = "SponsorSignature"; + private const string CounterpartyField = "CounterpartySignature"; + // SigningPubKey is intentionally NOT stripped: it is part of every signing // preimage, so all parts of one transaction must agree on it - a mismatch // means the parts were signed over different submitter forms. - private static readonly string[] SignatureFields = { "TxnSignature", "Signers", "SponsorSignature" }; + private static readonly string[] SignatureFields = { "TxnSignature", "Signers", SponsorField, CounterpartyField }; + + /// + /// One inner co-signature section (SponsorSignature or CounterpartySignature) + /// being assembled: either a single signature or a set of Signer entries. + /// + private sealed class InnerSection + { + public InnerSection(string field, string label, HashSet signerAccounts) + { + Field = field; + Label = label; + SignerAccounts = signerAccounts; + } + + public string Field { get; } + public string Label { get; } + public HashSet SignerAccounts { get; } + public SignatureObject? Single { get; set; } + public JsonArray Entries { get; } = new JsonArray(); + } /// /// Offline composition. Signer entries from accounts listed in /// go into - /// SponsorSignature.Signers; all other entries go into - /// tx.Signers. For ledger-driven routing use the - /// IXrplClient.ComposeSignatures extension instead. + /// SponsorSignature.Signers, entries from + /// into + /// CounterpartySignature.Signers (XLS-66 LoanSet borrower with a + /// SignerList); all other entries go into tx.Signers. For + /// ledger-driven routing use the IXrplClient.ComposeSignatures + /// extension instead. /// /// Partially signed blobs of the same transaction. /// Accounts whose Signer entries belong to the sponsor's SignerList. + /// Accounts whose Signer entries belong to the LoanSet counterparty's SignerList. public static SignatureResult ComposeSignatures( IEnumerable partBlobs, - IReadOnlyCollection? sponsorSignerAccounts = null) + IReadOnlyCollection? sponsorSignerAccounts = null, + IReadOnlyCollection? counterpartySignerAccounts = null) { List parts = partBlobs?.Select(b => XrplBinaryCodec.Decode(b).AsObject()).ToList() ?? throw new ValidationException("At least one partially signed blob is required."); if (parts.Count == 0) throw new ValidationException("At least one partially signed blob is required."); - HashSet sponsorSide = new HashSet( - (sponsorSignerAccounts ?? Array.Empty()).Select(SignerUtilities.NormalizeClassicAddress), - StringComparer.Ordinal); + InnerSection sponsor = new InnerSection(SponsorField, "sponsor", ToSet(sponsorSignerAccounts)); + InnerSection counterparty = new InnerSection(CounterpartyField, "counterparty", ToSet(counterpartySignerAccounts)); + InnerSection[] sections = { sponsor, counterparty }; + + foreach (string shared in sponsor.SignerAccounts.Intersect(counterparty.SignerAccounts, StringComparer.Ordinal)) + throw new ValidationException($"Ambiguous signer role for {shared}: listed as both a sponsor-side and a counterparty-side signer."); // All parts must agree on every non-signature field JsonObject canonical = parts[0].WithoutFields(SignatureFields); @@ -59,17 +91,14 @@ public static SignatureResult ComposeSignatures( string? mainPubKey = null; string? mainSignature = null; - SignatureObject? sponsorSingle = null; JsonArray accountEntries = new JsonArray(); - JsonArray sponsorEntries = new JsonArray(); void RouteEntry(JsonNode entry) { string account = entry?["Signer"]?["Account"]?.GetValue() ?? throw new ValidationException("Signer entry is missing the Account field."); - JsonArray target = sponsorSide.Contains(SignerUtilities.NormalizeClassicAddress(account)) - ? sponsorEntries - : accountEntries; + string normalized = SignerUtilities.NormalizeClassicAddress(account); + JsonArray target = sections.FirstOrDefault(s => s.SignerAccounts.Contains(normalized))?.Entries ?? accountEntries; target.Add(entry.DeepClone()); } @@ -93,43 +122,20 @@ void RouteEntry(JsonNode entry) RouteEntry(entry!); } - if (part["SponsorSignature"] is JsonObject sponsorJson) + foreach (InnerSection section in sections) { - SignatureObject parsed = SignatureObject.FromJsonObject(sponsorJson); - if (parsed.Signers is { Count: > 0 }) - { - // Entries pre-placed under SponsorSignature.Signers keep their - // explicit role: the producer asserted the sponsor side, so they - // are NOT re-routed by sponsorSide (offline compose may have none) - foreach (SignatureObject inner in parsed.Signers) - { - if (string.IsNullOrEmpty(inner.Account)) - throw new ValidationException("A SponsorSignature Signers entry is missing the Account field."); - if (string.IsNullOrEmpty(inner.SigningPubKey) || string.IsNullOrEmpty(inner.TxnSignature)) - throw new ValidationException("A SponsorSignature Signers entry is missing SigningPubKey or TxnSignature."); - sponsorEntries.Add(new JsonObject - { - ["Signer"] = SignatureObject - .Single(inner.SigningPubKey, inner.TxnSignature, inner.Account) - .ToJsonObject(), - }); - } - } - else if (!string.IsNullOrEmpty(parsed.TxnSignature)) - { - if (sponsorSingle is not null && - (!string.Equals(sponsorSingle.TxnSignature, parsed.TxnSignature, StringComparison.Ordinal) || - !string.Equals(sponsorSingle.SigningPubKey, parsed.SigningPubKey, StringComparison.Ordinal))) - throw new ValidationException("Multiple conflicting sponsor signatures supplied."); - sponsorSingle = parsed; - } + if (part[section.Field] is JsonObject innerJson) + CollectInner(section, innerJson); } } if (mainSignature is not null && accountEntries.Count > 0) throw new ValidationException("Both a single main signature and main-side Signer entries were supplied; a transaction carries one or the other."); - if (sponsorSingle is not null && sponsorEntries.Count > 0) - throw new ValidationException("Both a single sponsor signature and sponsor-side Signer entries were supplied; SponsorSignature carries one or the other."); + foreach (InnerSection section in sections) + { + if (section.Single is not null && section.Entries.Count > 0) + throw new ValidationException($"Both a single {section.Label} signature and {section.Label}-side Signer entries were supplied; {section.Field} carries one or the other."); + } if (mainSignature is null && accountEntries.Count == 0) throw new ValidationException("No main signature material supplied. The transaction is not signed by all participants."); @@ -145,21 +151,64 @@ void RouteEntry(JsonNode entry) result["Signers"] = SignerUtilities.DedupeAndSortSigners(accountEntries); } - if (sponsorSingle is not null) - { - result["SponsorSignature"] = sponsorSingle.ToJsonObject(); - } - else if (sponsorEntries.Count > 0) + foreach (InnerSection section in sections) { - result["SponsorSignature"] = new JsonObject + if (section.Single is not null) { - ["SigningPubKey"] = "", - ["Signers"] = SignerUtilities.DedupeAndSortSigners(sponsorEntries), - }; + result[section.Field] = section.Single.ToJsonObject(); + } + else if (section.Entries.Count > 0) + { + result[section.Field] = new JsonObject + { + ["SigningPubKey"] = "", + ["Signers"] = SignerUtilities.DedupeAndSortSigners(section.Entries), + }; + } } string txBlob = XrplBinaryCodec.Encode(result); return new SignatureResult(txBlob, HashLedger.HashSignedTx(txBlob)); } + + /// + /// Folds one part's inner signature object into its section. Entries + /// pre-placed under the section's Signers keep their explicit role: the + /// producer asserted the side, so they are NOT re-routed by the account + /// sets (offline compose may have none). + /// + private static void CollectInner(InnerSection section, JsonObject innerJson) + { + SignatureObject parsed = SignatureObject.FromJsonObject(innerJson); + if (parsed.Signers is { Count: > 0 }) + { + foreach (SignatureObject inner in parsed.Signers) + { + if (string.IsNullOrEmpty(inner.Account)) + throw new ValidationException($"A {section.Field} Signers entry is missing the Account field."); + if (string.IsNullOrEmpty(inner.SigningPubKey) || string.IsNullOrEmpty(inner.TxnSignature)) + throw new ValidationException($"A {section.Field} Signers entry is missing SigningPubKey or TxnSignature."); + section.Entries.Add(new JsonObject + { + ["Signer"] = SignatureObject + .Single(inner.SigningPubKey, inner.TxnSignature, inner.Account) + .ToJsonObject(), + }); + } + } + else if (!string.IsNullOrEmpty(parsed.TxnSignature)) + { + if (section.Single is not null && + (!string.Equals(section.Single.TxnSignature, parsed.TxnSignature, StringComparison.Ordinal) || + !string.Equals(section.Single.SigningPubKey, parsed.SigningPubKey, StringComparison.Ordinal))) + throw new ValidationException($"Multiple conflicting {section.Label} signatures supplied."); + section.Single = parsed; + } + } + + private static HashSet ToSet(IReadOnlyCollection? accounts) => + new HashSet( + (accounts ?? Array.Empty()).Select(SignerUtilities.NormalizeClassicAddress), + StringComparer.Ordinal); } } From b63b62ff29f61a6f74eee402d58b3e11a6f02218 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 08:31:56 -0300 Subject: [PATCH 05/26] feat(xchain): witness-side signing of bridge attestations 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: the order rippled assigns is the canonical sort order, so a codec regression on STXChainBridge or on field ordering fails in the unit test rather than as temXCHAIN_BAD_PROOF on a node. The whole witness half runs against one standalone node: 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. --- DocFx/XChainBridge-Guide.md | 12 +- DocFx/XChainBridge-Guide.ru.md | 12 +- .../transactions/TestIXChainAttestation.cs | 242 ++++++++++++++++++ .../Wallet/TestUXChainAttestationSigner.cs | 200 +++++++++++++++ Xrpl/Wallet/XChainAttestationSigner.cs | 187 ++++++++++++++ 5 files changed, 647 insertions(+), 6 deletions(-) create mode 100644 Tests/Xrpl.Tests/Integration/transactions/TestIXChainAttestation.cs create mode 100644 Tests/Xrpl.Tests/Wallet/TestUXChainAttestationSigner.cs create mode 100644 Xrpl/Wallet/XChainAttestationSigner.cs diff --git a/DocFx/XChainBridge-Guide.md b/DocFx/XChainBridge-Guide.md index aae8d963..079596ce 100644 --- a/DocFx/XChainBridge-Guide.md +++ b/DocFx/XChainBridge-Guide.md @@ -215,17 +215,23 @@ XChainAddClaimAttestation attestation = new XChainAddClaimAttestation XChainClaimID = "1", Amount = new Currency { Value = "1000000", CurrencyCode = "XRP" }, OtherChainSource = walletUser.ClassicAddress, - AttestationSignerAccount = witnessAccount.ClassicAddress, AttestationRewardAccount = witnessAccount.ClassicAddress, - PublicKey = witnessPublicKeyHex, - Signature = attestationSignatureHex, WasLockingChainSend = 1, // 1 = locking chain, 0 = issuing chain Destination = destinationAddress, }; + +// The witness signs the attested facts (not the transaction): PublicKey, Signature and, +// when unset, AttestationSignerAccount are filled from the witness wallet +XChainAttestationSigner.SignClaimAttestation(attestation, witnessAccount); + attestation = await client.Autofill(attestation); TransactionSummary result = await client.SubmitAndWait(attestation, witnessAccount, true); ``` +The signed message is the canonical serialization of the attested fields alone (rippled `AttestationClaim::message`): no hash prefix, no `Account`, `Fee` or `Sequence`, so any funded account can submit the transaction and the node still verifies the signature against `PublicKey`. The key must be the master or regular key of `AttestationSignerAccount`, and that account must be on the door's `SignerList`. `XChainAttestationSigner.VerifyClaimAttestation` checks a received attestation the same way the node does before it is submitted. `XChainAddAccountCreateAttestation` is signed with `SignAccountCreateAttestation`. + +The amount carries the issue of the chain the send happened on: an attestation for a commit on the locking chain names the `LockingChainIssue`, one for the issuing chain the `IssuingChainIssue`. + ### 5. Claim Value (Receive on Destination Chain) Once sufficient attestations are collected, the user claims value on the issuing chain: diff --git a/DocFx/XChainBridge-Guide.ru.md b/DocFx/XChainBridge-Guide.ru.md index 262eb2a4..9e72d885 100644 --- a/DocFx/XChainBridge-Guide.ru.md +++ b/DocFx/XChainBridge-Guide.ru.md @@ -215,17 +215,23 @@ XChainAddClaimAttestation attestation = new XChainAddClaimAttestation XChainClaimID = "1", Amount = new Currency { Value = "1000000", CurrencyCode = "XRP" }, OtherChainSource = walletUser.ClassicAddress, - AttestationSignerAccount = witnessAccount.ClassicAddress, AttestationRewardAccount = witnessAccount.ClassicAddress, - PublicKey = witnessPublicKeyHex, - Signature = attestationSignatureHex, WasLockingChainSend = 1, // 1 = locking chain, 0 = issuing chain Destination = destinationAddress, }; + +// Witness подписывает аттестуемые факты (не транзакцию): PublicKey, Signature и, +// если не задан, AttestationSignerAccount заполняются из кошелька witness-сервера +XChainAttestationSigner.SignClaimAttestation(attestation, witnessAccount); + attestation = await client.Autofill(attestation); TransactionSummary result = await client.SubmitAndWait(attestation, witnessAccount, true); ``` +Подписывается каноническая сериализация только аттестуемых полей (rippled `AttestationClaim::message`): без хеш-префикса, без `Account`, `Fee` и `Sequence`, поэтому отправить транзакцию может любой профинансированный аккаунт, а нода всё равно проверит подпись по `PublicKey`. Ключ обязан быть мастер- или regular-ключом `AttestationSignerAccount`, а сам аккаунт — входить в `SignerList` door-аккаунта. `XChainAttestationSigner.VerifyClaimAttestation` проверяет полученную аттестацию так же, как нода, ещё до отправки. `XChainAddAccountCreateAttestation` подписывается через `SignAccountCreateAttestation`. + +Сумма указывается в валюте той цепочки, где произошла отправка: аттестация commit на locking chain несёт `LockingChainIssue`, на issuing chain — `IssuingChainIssue`. + ### 5. Claim (получение на целевой цепочке) После накопления достаточного количества аттестаций пользователь получает средства на issuing chain: diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIXChainAttestation.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIXChainAttestation.cs new file mode 100644 index 00000000..cd09268f --- /dev/null +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIXChainAttestation.cs @@ -0,0 +1,242 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.Client; +using Xrpl.Client.Exceptions; +using Xrpl.Models; +using Xrpl.Models.Common; +using Xrpl.Models.Ledger; +using Xrpl.Models.Methods; +using Xrpl.Models.Transactions; +using Xrpl.Sugar; +using Xrpl.Wallet; + +using static Xrpl.Models.Common.Common; + +namespace XrplTests.Xrpl.ClientLib.Integration; + +/// +/// The witness half of XLS-38 on one node. rippled resolves a bridge spec to the +/// locking-side Bridge entry first (readOrpeekBridge), so with only the locking door's +/// bridge on the ledger every bridge transaction is processed as the locking chain: +/// commits lock funds in the door, and an attestation that says the send happened on +/// the issuing chain (WasLockingChainSend = 0) releases them to the destination here. +/// That is enough to drive the whole flow - claim id, commit, witness attestation, +/// delivery or explicit claim, account creation - against a single standalone node. +/// +[TestClass] +[TestCategory("XChain")] +public class TestIXChainAttestation : TestIXChainBridgeBase +{ + private static IXrplClient client; + protected override IXrplClient GetClient() => client; + + private static bool xchainEnabled; + + [ClassInitialize] + public static async Task ClassInitializeAsync(TestContext testContext) + { + client = await CreateStandaloneClient(); + xchainEnabled = await AmendmentGuard.IsEnabledAsync(client, AmendmentGuard.XChainBridge); + } + + [TestInitialize] + public void CheckXChainBridgeAmendment() + { + if (!xchainEnabled) + { + Assert.Inconclusive("XChainBridge amendment is not enabled on the test node."); + } + } + + [ClassCleanup] + public static void ClassCleanup() => client?.Dispose(); + + private static Currency Drops(string drops) => new Currency { Value = drops, CurrencyCode = "XRP" }; + + private static Currency Iou(string issuer, string value) => new Currency { CurrencyCode = TestCurrencyCode, Issuer = issuer, Value = value }; + + private static async Task SubmitAsync(ITransactionRequest tx, XrplWallet signer) + { + ITransactionRequest autofilled = await client.Autofill(tx); + ValidateResult(await client.SubmitAndWait(autofilled, signer, true)); + } + + private static async Task SetWitnessesAsync(XrplWallet door, uint quorum, params XrplWallet[] witnesses) + { + await SubmitAsync(new SignerListSet + { + Account = door.ClassicAddress, + SignerQuorum = quorum, + SignerEntries = witnesses + .Select(w => new SignerEntryWrapper { SignerEntry = new SignerEntry { Account = w.ClassicAddress, SignerWeight = 1 } }) + .ToList(), + }, door); + } + + private static async Task IouBalanceAsync(string holder, string issuer) + { + AccountLines lines = await client.AccountLines(new AccountLinesRequest(holder)).Typed(); + TrustLine line = lines.TrustLines?.FirstOrDefault(l => l.Account == issuer && l.Currency == TestCurrencyCode); + return line?.BalanceAsNumber ?? 0m; + } + + private static async Task CountObjectsAsync(string account, LedgerEntryType type) + { + AccountObjects objects = await client.AccountObjects(new AccountObjectsRequest(account) { Type = type }).Typed(); + return objects.AccountObjectList?.Count ?? 0; + } + + private sealed record IouBridge(XrplWallet Door, XrplWallet Issuer, XrplWallet Witness, XrplWallet User, XrplWallet Recipient, XChainBridgeModel Bridge); + + /// + /// Locking door, IOU issuer, one witness, a user holding 1000 USD and a recipient + /// with a trust line; bridge created on the door with the witness as its signer list; + /// claim id 1 created by the recipient; 100 USD committed by the user. + /// + private static async Task CommitOnIouBridgeAsync() + { + XrplWallet door = XrplWallet.Generate(); + XrplWallet issuer = XrplWallet.Generate(); + XrplWallet witness = XrplWallet.Generate(); + XrplWallet user = XrplWallet.Generate(); + XrplWallet recipient = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, door, issuer, witness, user, recipient); + + await EnableDefaultRipple(client, issuer); + await SetupTrustLine(client, door, issuer.ClassicAddress); + await SetupTrustLine(client, user, issuer.ClassicAddress); + await SetupTrustLine(client, recipient, issuer.ClassicAddress); + await SubmitAsync(new Payment { Account = issuer.ClassicAddress, Destination = user.ClassicAddress, Amount = Iou(issuer.ClassicAddress, "1000") }, issuer); + + // The issuing door only has to be an address in the spec: nothing on this ledger looks it up + XChainBridgeModel bridge = CreateIouTestBridge(door.ClassicAddress, issuer.ClassicAddress, XrplWallet.Generate().ClassicAddress); + await SubmitAsync(new XChainCreateBridge { Account = door.ClassicAddress, XChainBridge = bridge, SignatureReward = Drops("100") }, door); + await SetWitnessesAsync(door, 1, witness); + + await SubmitAsync(new XChainCreateClaimID { Account = recipient.ClassicAddress, XChainBridge = bridge, SignatureReward = Drops("100"), OtherChainSource = user.ClassicAddress }, recipient); + await SubmitAsync(new XChainCommit { Account = user.ClassicAddress, XChainBridge = bridge, XChainClaimID = "1", Amount = Iou(issuer.ClassicAddress, "100"), OtherChainDestination = recipient.ClassicAddress }, user); + + return new IouBridge(door, issuer, witness, user, recipient, bridge); + } + + private static XChainAddClaimAttestation ClaimAttestation(IouBridge setup, string destination) => new XChainAddClaimAttestation + { + Account = setup.Witness.ClassicAddress, + XChainBridge = setup.Bridge, + OtherChainSource = setup.User.ClassicAddress, + // The attested send is on the issuing chain, so the amount carries the issuing chain issue + Amount = new Currency { CurrencyCode = TestCurrencyCode, Issuer = setup.Bridge.IssuingChainDoor, Value = "100" }, + AttestationRewardAccount = setup.Witness.ClassicAddress, + Destination = destination, + WasLockingChainSend = 0, + XChainClaimID = "1", + }; + + [TestMethod] + public async Task TestXChainAddClaimAttestation_WithDestination_DeliversOnQuorum() + { + IouBridge setup = await CommitOnIouBridgeAsync(); + Assert.AreEqual(1, await CountObjectsAsync(setup.Recipient.ClassicAddress, LedgerEntryType.XChainOwnedClaimID)); + + XChainAddClaimAttestation attestation = XChainAttestationSigner.SignClaimAttestation(ClaimAttestation(setup, setup.Recipient.ClassicAddress), setup.Witness); + Assert.IsTrue(XChainAttestationSigner.VerifyClaimAttestation(attestation), "the attestation must verify locally before submission"); + await SubmitAsync(attestation, setup.Witness); + + Assert.AreEqual(100m, await IouBalanceAsync(setup.Recipient.ClassicAddress, setup.Issuer.ClassicAddress), "quorum of one delivers the committed amount to the destination"); + Assert.AreEqual(0, await CountObjectsAsync(setup.Recipient.ClassicAddress, LedgerEntryType.XChainOwnedClaimID), "the claim id is consumed by the delivery"); + } + + [TestMethod] + public async Task TestXChainClaim_AfterAttestationWithoutDestination_UsesDestinationTag() + { + IouBridge setup = await CommitOnIouBridgeAsync(); + + XChainAddClaimAttestation attestation = XChainAttestationSigner.SignClaimAttestation(ClaimAttestation(setup, destination: null), setup.Witness); + await SubmitAsync(attestation, setup.Witness); + Assert.AreEqual(0m, await IouBalanceAsync(setup.Recipient.ClassicAddress, setup.Issuer.ClassicAddress), "without a destination the funds wait for XChainClaim"); + + await SubmitAsync(new XChainClaim + { + Account = setup.Recipient.ClassicAddress, + XChainBridge = setup.Bridge, + XChainClaimID = "1", + Destination = setup.Recipient.ClassicAddress, + DestinationTag = 7, + Amount = Iou(setup.Issuer.ClassicAddress, "100"), + }, setup.Recipient); + + Assert.AreEqual(100m, await IouBalanceAsync(setup.Recipient.ClassicAddress, setup.Issuer.ClassicAddress)); + Assert.AreEqual(0, await CountObjectsAsync(setup.Recipient.ClassicAddress, LedgerEntryType.XChainOwnedClaimID)); + } + + [TestMethod] + public async Task TestXChainAddClaimAttestation_UnlistedWitness_IsRejected() + { + IouBridge setup = await CommitOnIouBridgeAsync(); + XrplWallet stranger = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, stranger); + + XChainAddClaimAttestation attestation = ClaimAttestation(setup, setup.Recipient.ClassicAddress); + attestation.Account = stranger.ClassicAddress; + attestation.AttestationRewardAccount = stranger.ClassicAddress; + XChainAttestationSigner.SignClaimAttestation(attestation, stranger); + + ITransactionRequest autofilled = await client.Autofill(attestation); + TransactionFailedException ex = await Assert.ThrowsExactlyAsync( + () => client.SubmitAndWait(autofilled, stranger, true)); + StringAssert.Contains(ex.Message, "tecNO_PERMISSION"); + Assert.AreEqual(0m, await IouBalanceAsync(setup.Recipient.ClassicAddress, setup.Issuer.ClassicAddress)); + } + + [TestMethod] + public async Task TestXChainAddAccountCreateAttestation_TwoWitnesses_CreatesTheAccountOnQuorum() + { + XrplWallet door = XrplWallet.Generate(); + XrplWallet witness1 = XrplWallet.Generate(); + XrplWallet witness2 = XrplWallet.Generate(); + XrplWallet user = XrplWallet.Generate(); + XrplWallet created = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, door, witness1, witness2, user); + + XChainBridgeModel bridge = CreateXrpTestBridge(door.ClassicAddress); + await SubmitAsync(new XChainCreateBridge { Account = door.ClassicAddress, XChainBridge = bridge, SignatureReward = Drops("100"), MinAccountCreateAmount = Drops("10000000") }, door); + await SetWitnessesAsync(door, 2, witness1, witness2); + + await SubmitAsync(new XChainAccountCreateCommit + { + Account = user.ClassicAddress, + XChainBridge = bridge, + Destination = created.ClassicAddress, + Amount = Drops("20000000"), + SignatureReward = Drops("100"), + }, user); + + XChainAddAccountCreateAttestation Attestation(XrplWallet witness) => XChainAttestationSigner.SignAccountCreateAttestation(new XChainAddAccountCreateAttestation + { + Account = witness.ClassicAddress, + XChainBridge = bridge, + XChainAccountCreateCount = "1", + Amount = Drops("20000000"), + SignatureReward = Drops("100"), + OtherChainSource = user.ClassicAddress, + Destination = created.ClassicAddress, + AttestationRewardAccount = witness.ClassicAddress, + WasLockingChainSend = 0, + }, witness); + + // First attestation: below quorum, the door records it in an XChainOwnedCreateAccountClaimID + await SubmitAsync(Attestation(witness1), witness1); + Assert.AreEqual(1, await CountObjectsAsync(door.ClassicAddress, LedgerEntryType.XChainOwnedCreateAccountClaimID), "one attestation of two is parked on the door"); + await Assert.ThrowsExactlyAsync(() => client.AccountInfo(new AccountInfoRequest(created.ClassicAddress)).Typed(), "the account must not exist before quorum"); + + // Second attestation: quorum reached, the account is created from the door's funds + await SubmitAsync(Attestation(witness2), witness2); + AccountInfo info = await client.AccountInfo(new AccountInfoRequest(created.ClassicAddress)).Typed(); + Assert.AreEqual("20000000", info.AccountData.Balance.Value, "the created account holds the committed amount"); + Assert.AreEqual(0, await CountObjectsAsync(door.ClassicAddress, LedgerEntryType.XChainOwnedCreateAccountClaimID), "the create-account claim id is consumed"); + } +} diff --git a/Tests/Xrpl.Tests/Wallet/TestUXChainAttestationSigner.cs b/Tests/Xrpl.Tests/Wallet/TestUXChainAttestationSigner.cs new file mode 100644 index 00000000..053e1fdf --- /dev/null +++ b/Tests/Xrpl.Tests/Wallet/TestUXChainAttestationSigner.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Xrpl.AddressCodec; +using Xrpl.Client.Exceptions; +using Xrpl.Models.Common; +using Xrpl.Models.Transactions; +using Xrpl.Wallet; + +using static Xrpl.Models.Common.Common; + +namespace XrplTests.Xrpl.Wallet +{ + /// + /// The attestation message a witness signs is a canonical STObject with no hash + /// prefix (rippled AttestationClaim::message / AttestationCreateAccount::message). + /// The layout tests spell the expected bytes out field by field from the XRPL binary + /// format, independent of the SDK's codec, so a codec regression on STXChainBridge or + /// on field ordering shows up here rather than as temXCHAIN_BAD_PROOF on a node. + /// + [TestClass] + public class TestUXChainAttestationSigner + { + private const string LockingDoor = "rN7n7otQDd6FczFgLdSqtcsAUxDkw6fzRH"; + private const string IssuingDoor = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"; + private const string Source = "rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe"; + private const string Reward = "rrrrrrrrrrrrrrrrrrrrBZbvji"; + private const string Destination = "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn"; + + private static XChainBridgeModel XrpBridge() => new XChainBridgeModel + { + LockingChainDoor = LockingDoor, + LockingChainIssue = new IssuedCurrency { Currency = "XRP" }, + IssuingChainDoor = IssuingDoor, + IssuingChainIssue = new IssuedCurrency { Currency = "XRP" }, + }; + + private static IEnumerable AccountField(byte fieldCode, string address) + { + // type AccountID = 8 (< 16), field code < 16: one header byte 0x8n + yield return (byte)(0x80 | fieldCode); + yield return 0x14; + foreach (byte b in XrplCodec.DecodeAccountID(address)) yield return b; + } + + private static IEnumerable AccountFieldWide(byte fieldCode, string address) + { + // type AccountID = 8 (< 16), field code >= 16: 0x80 then the field code + yield return 0x80; + yield return fieldCode; + yield return 0x14; + foreach (byte b in XrplCodec.DecodeAccountID(address)) yield return b; + } + + private static IEnumerable XrpAmount(byte typeHeaderFirst, byte? typeHeaderSecond, ulong drops) + { + yield return typeHeaderFirst; + if (typeHeaderSecond is { } second) yield return second; + ulong encoded = 0x4000000000000000UL | drops; + for (int shift = 56; shift >= 0; shift -= 8) yield return (byte)(encoded >> shift); + } + + private static IEnumerable UInt64Field(byte fieldCode, ulong value) + { + // type UInt64 = 3 (< 16), field code >= 16: 0x30 then the field code + yield return 0x30; + yield return fieldCode; + for (int shift = 56; shift >= 0; shift -= 8) yield return (byte)(value >> shift); + } + + private static IEnumerable XrpXrpBridge() + { + // type XChainBridge = 25 (>= 16), field code 1 (< 16): field code then the type + yield return 0x01; + yield return 0x19; + yield return 0x14; + foreach (byte b in XrplCodec.DecodeAccountID(LockingDoor)) yield return b; + foreach (byte b in new byte[20]) yield return b; // XRP issue: 160-bit zero currency, no issuer + yield return 0x14; + foreach (byte b in XrplCodec.DecodeAccountID(IssuingDoor)) yield return b; + foreach (byte b in new byte[20]) yield return b; + } + + [TestMethod] + public void TestUClaimMessage_MatchesRippledLayout() + { + byte[] message = XChainAttestationSigner.ClaimMessage( + XrpBridge(), Source, new Currency { ValueAsXrp = 1m }, Reward, wasLockingChainSend: true, "1", Destination); + + List expected = new List(); + expected.AddRange(UInt64Field(20, 1)); // XChainClaimID + expected.AddRange(XrpAmount(0x61, null, 1_000_000)); // Amount (type 6, field 1) + expected.AddRange(AccountField(3, Destination)); // Destination + expected.AddRange(AccountFieldWide(18, Source)); // OtherChainSource + expected.AddRange(AccountFieldWide(21, Reward)); // AttestationRewardAccount + expected.AddRange(new byte[] { 0x00, 0x10, 0x13, 0x01 }); // WasLockingChainSend (UInt8, field 19) + expected.AddRange(XrpXrpBridge()); // XChainBridge + + Assert.AreEqual(Convert.ToHexString(expected.ToArray()), Convert.ToHexString(message)); + } + + [TestMethod] + public void TestUClaimMessage_WithoutDestination_OmitsTheField() + { + byte[] withDestination = XChainAttestationSigner.ClaimMessage( + XrpBridge(), Source, new Currency { ValueAsXrp = 1m }, Reward, false, "1", Destination); + byte[] without = XChainAttestationSigner.ClaimMessage( + XrpBridge(), Source, new Currency { ValueAsXrp = 1m }, Reward, false, "1", null); + + Assert.AreEqual(withDestination.Length - 22, without.Length, "Destination is a 22-byte optional field"); + CollectionAssert.DoesNotContain(without.ToList(), (byte)0x83, "no AccountID field 3 header without a destination"); + } + + [TestMethod] + public void TestUAccountCreateMessage_MatchesRippledLayout() + { + byte[] message = XChainAttestationSigner.AccountCreateMessage( + XrpBridge(), Source, new Currency { ValueAsXrp = 20m }, new Currency { Value = "100", CurrencyCode = "XRP" }, + Destination, Reward, wasLockingChainSend: false, "1"); + + List expected = new List(); + expected.AddRange(UInt64Field(21, 1)); // XChainAccountCreateCount + expected.AddRange(XrpAmount(0x61, null, 20_000_000)); // Amount + expected.AddRange(XrpAmount(0x60, 0x1D, 100)); // SignatureReward (type 6, field 29) + expected.AddRange(AccountField(3, Destination)); // Destination + expected.AddRange(AccountFieldWide(18, Source)); // OtherChainSource + expected.AddRange(AccountFieldWide(21, Reward)); // AttestationRewardAccount + expected.AddRange(new byte[] { 0x00, 0x10, 0x13, 0x00 }); // WasLockingChainSend = 0 + expected.AddRange(XrpXrpBridge()); // XChainBridge + + Assert.AreEqual(Convert.ToHexString(expected.ToArray()), Convert.ToHexString(message)); + } + + [DataTestMethod] + [DataRow("ed25519")] + [DataRow("secp256k1")] + public void TestUSignClaimAttestation_VerifiesAndDetectsTampering(string algorithm) + { + XrplWallet witness = XrplWallet.Generate(algorithm); + XChainAddClaimAttestation attestation = new XChainAddClaimAttestation + { + Account = witness.ClassicAddress, + XChainBridge = XrpBridge(), + OtherChainSource = Source, + Amount = new Currency { ValueAsXrp = 1m }, + AttestationRewardAccount = witness.ClassicAddress, + Destination = Destination, + WasLockingChainSend = 1, + XChainClaimID = "1", + }; + + XChainAttestationSigner.SignClaimAttestation(attestation, witness); + + Assert.AreEqual(witness.PublicKey, attestation.PublicKey); + Assert.AreEqual(witness.ClassicAddress, attestation.AttestationSignerAccount, "the signer account defaults to the witness"); + Assert.IsTrue(XChainAttestationSigner.VerifyClaimAttestation(attestation)); + + attestation.Amount = new Currency { ValueAsXrp = 2m }; + Assert.IsFalse(XChainAttestationSigner.VerifyClaimAttestation(attestation), "a changed amount must not verify"); + } + + [TestMethod] + public void TestUSignAccountCreateAttestation_Verifies() + { + XrplWallet witness = XrplWallet.Generate(); + XChainAddAccountCreateAttestation attestation = new XChainAddAccountCreateAttestation + { + Account = witness.ClassicAddress, + XChainBridge = XrpBridge(), + XChainAccountCreateCount = "1", + Amount = new Currency { ValueAsXrp = 20m }, + SignatureReward = new Currency { Value = "100", CurrencyCode = "XRP" }, + OtherChainSource = Source, + Destination = Destination, + AttestationRewardAccount = witness.ClassicAddress, + AttestationSignerAccount = Reward, + WasLockingChainSend = 0, + }; + + XChainAttestationSigner.SignAccountCreateAttestation(attestation, witness); + + Assert.AreEqual(Reward, attestation.AttestationSignerAccount, "an explicit signer account is kept"); + Assert.IsTrue(XChainAttestationSigner.VerifyAccountCreateAttestation(attestation)); + + attestation.WasLockingChainSend = 1; + Assert.IsFalse(XChainAttestationSigner.VerifyAccountCreateAttestation(attestation), "a flipped direction must not verify"); + } + + [TestMethod] + public void TestUClaimMessage_MissingField_Throws() + { + ValidationException ex = Assert.ThrowsExactly(() => + XChainAttestationSigner.ClaimMessage(XrpBridge(), Source, new Currency { ValueAsXrp = 1m }, "", true, "1", null)); + StringAssert.Contains(ex.Message, "attestationRewardAccount"); + } + } +} diff --git a/Xrpl/Wallet/XChainAttestationSigner.cs b/Xrpl/Wallet/XChainAttestationSigner.cs new file mode 100644 index 00000000..c9f7940c --- /dev/null +++ b/Xrpl/Wallet/XChainAttestationSigner.cs @@ -0,0 +1,187 @@ +#nullable enable +using System; +using System.Text.Json; +using System.Text.Json.Nodes; + +using Xrpl.BinaryCodec; +using Xrpl.Client.Exceptions; +using Xrpl.Client.Json; +using Xrpl.Keypairs; +using Xrpl.Models.Common; +using Xrpl.Models.Transactions; + +namespace Xrpl.Wallet +{ + /// + /// Witness-side signing of XLS-38 bridge attestations. A witness that saw an + /// XChainCommit or XChainCreateAccountCommit on one chain attests it on the + /// other by submitting XChainAddClaimAttestation / + /// XChainAddAccountCreateAttestation carrying its public key and a signature over + /// the attested facts. The signed message is the canonical serialization of an STObject + /// holding exactly those facts (rippled AttestationClaim::message / + /// AttestationCreateAccount::message): no hash prefix, no transaction fields, so the + /// same bytes verify regardless of which account submits the attestation transaction. + /// + public static class XChainAttestationSigner + { + /// + /// Builds the bytes a witness signs for a claim attestation, from the fields the + /// attestation transaction will carry. + /// + public static byte[] ClaimMessage( + XChainBridgeModel bridge, + string otherChainSource, + Currency amount, + string attestationRewardAccount, + bool wasLockingChainSend, + string xChainClaimId, + string? destination) + { + JsonObject message = new JsonObject + { + ["XChainClaimID"] = Require(xChainClaimId, nameof(xChainClaimId)), + ["Amount"] = ToNode(Require(amount, nameof(amount))), + ["OtherChainSource"] = Require(otherChainSource, nameof(otherChainSource)), + ["AttestationRewardAccount"] = Require(attestationRewardAccount, nameof(attestationRewardAccount)), + ["WasLockingChainSend"] = JsonValue.Create((byte)(wasLockingChainSend ? 1 : 0)), + ["XChainBridge"] = ToNode(Require(bridge, nameof(bridge))), + }; + if (!string.IsNullOrEmpty(destination)) + message["Destination"] = destination; + + return AddressCodec.Utils.FromHex(XrplBinaryCodec.Encode(message)); + } + + /// + /// Builds the bytes a witness signs for an account-create attestation. + /// + public static byte[] AccountCreateMessage( + XChainBridgeModel bridge, + string otherChainSource, + Currency amount, + Currency signatureReward, + string destination, + string attestationRewardAccount, + bool wasLockingChainSend, + string xChainAccountCreateCount) + { + JsonObject message = new JsonObject + { + ["XChainAccountCreateCount"] = Require(xChainAccountCreateCount, nameof(xChainAccountCreateCount)), + ["Amount"] = ToNode(Require(amount, nameof(amount))), + ["SignatureReward"] = ToNode(Require(signatureReward, nameof(signatureReward))), + ["Destination"] = Require(destination, nameof(destination)), + ["OtherChainSource"] = Require(otherChainSource, nameof(otherChainSource)), + ["AttestationRewardAccount"] = Require(attestationRewardAccount, nameof(attestationRewardAccount)), + ["WasLockingChainSend"] = JsonValue.Create((byte)(wasLockingChainSend ? 1 : 0)), + ["XChainBridge"] = ToNode(Require(bridge, nameof(bridge))), + }; + + return AddressCodec.Utils.FromHex(XrplBinaryCodec.Encode(message)); + } + + /// + /// Signs the attestation with the witness key: fills PublicKey and + /// Signature from the transaction's own fields, and + /// AttestationSignerAccount with the witness address when it is not set. + /// The submitting Account may be any funded account. + /// + public static XChainAddClaimAttestation SignClaimAttestation(XChainAddClaimAttestation attestation, XrplWallet witness) + { + if (attestation is null) throw new ArgumentNullException(nameof(attestation)); + if (witness is null) throw new ArgumentNullException(nameof(witness)); + + attestation.AttestationSignerAccount ??= witness.ClassicAddress; + byte[] message = ClaimMessage( + attestation.XChainBridge, + attestation.OtherChainSource, + attestation.Amount, + attestation.AttestationRewardAccount, + IsSet(attestation.WasLockingChainSend), + attestation.XChainClaimID, + attestation.Destination); + + attestation.PublicKey = witness.PublicKey; + attestation.Signature = XrplKeypairs.Sign(message, witness.PrivateKey); + return attestation; + } + + /// + /// Signs the account-create attestation with the witness key; see + /// . + /// + public static XChainAddAccountCreateAttestation SignAccountCreateAttestation(XChainAddAccountCreateAttestation attestation, XrplWallet witness) + { + if (attestation is null) throw new ArgumentNullException(nameof(attestation)); + if (witness is null) throw new ArgumentNullException(nameof(witness)); + + attestation.AttestationSignerAccount ??= witness.ClassicAddress; + byte[] message = AccountCreateMessage( + attestation.XChainBridge, + attestation.OtherChainSource, + attestation.Amount, + attestation.SignatureReward, + attestation.Destination, + attestation.AttestationRewardAccount, + IsSet(attestation.WasLockingChainSend), + attestation.XChainAccountCreateCount); + + attestation.PublicKey = witness.PublicKey; + attestation.Signature = XrplKeypairs.Sign(message, witness.PrivateKey); + return attestation; + } + + /// + /// Checks the attestation's signature against its own fields and public key, + /// the way rippled's attestationPreflight does (temXCHAIN_BAD_PROOF otherwise). + /// + public static bool VerifyClaimAttestation(XChainAddClaimAttestation attestation) + { + if (attestation is null) throw new ArgumentNullException(nameof(attestation)); + if (string.IsNullOrEmpty(attestation.PublicKey) || string.IsNullOrEmpty(attestation.Signature)) + return false; + + byte[] message = ClaimMessage( + attestation.XChainBridge, + attestation.OtherChainSource, + attestation.Amount, + attestation.AttestationRewardAccount, + IsSet(attestation.WasLockingChainSend), + attestation.XChainClaimID, + attestation.Destination); + return XrplKeypairs.Verify(message, attestation.Signature, attestation.PublicKey); + } + + /// + /// Checks the account-create attestation's signature; see . + /// + public static bool VerifyAccountCreateAttestation(XChainAddAccountCreateAttestation attestation) + { + if (attestation is null) throw new ArgumentNullException(nameof(attestation)); + if (string.IsNullOrEmpty(attestation.PublicKey) || string.IsNullOrEmpty(attestation.Signature)) + return false; + + byte[] message = AccountCreateMessage( + attestation.XChainBridge, + attestation.OtherChainSource, + attestation.Amount, + attestation.SignatureReward, + attestation.Destination, + attestation.AttestationRewardAccount, + IsSet(attestation.WasLockingChainSend), + attestation.XChainAccountCreateCount); + return XrplKeypairs.Verify(message, attestation.Signature, attestation.PublicKey); + } + + private static bool IsSet(byte? wasLockingChainSend) => wasLockingChainSend is > 0; + + private static JsonNode ToNode(object value) => + JsonSerializer.SerializeToNode(value, XrplJsonOptions.Default) + ?? throw new ValidationException($"{value.GetType().Name} serialized to null."); + + private static T Require(T? value, string name) where T : class => + value is null || (value is string s && s.Length == 0) + ? throw new ValidationException($"Attestation field {name} is required.") + : value; + } +} From 90e67b97aa9666a9f0fe1a17879c4d1ee03b4f15 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 08:31:56 -0300 Subject: [PATCH 06/26] chore(release): 11.3.0.0 --- CHANGES.md | 20 ++++++++++++++++++++ Xrpl/Xrpl.csproj | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index eab3c5ff..d4284945 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,25 @@ # Changes +## 11.3.0.0 03/09/2026 + +* **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`) + * 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 + * `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. diff --git a/Xrpl/Xrpl.csproj b/Xrpl/Xrpl.csproj index 0614fa8d..fdd2b4a5 100644 --- a/Xrpl/Xrpl.csproj +++ b/Xrpl/Xrpl.csproj @@ -14,7 +14,7 @@ Apache-2.0 https://github.com/StaticBit-io/XrplCSharp XrplCSharp - 11.2.0.0 + 11.3.0.0 From e65e9673cee47440ad509619487f8ac361913ae4 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 10:15:09 -0300 Subject: [PATCH 07/26] fix(wallet): 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. Its 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. The balance was also polled twice per wallet: the faucet answers with the destination account, so the second poll re-read an address that had just been read. Found by running the integration suite against devnet, where the standalone stand never calls the faucet: 21 of 22 sponsored-type tests failed on it. Verified the same way - one process, some sixty faucet calls, all funded. The faucet call news up its own HttpClient, so the helper cannot be exercised without the network and there is no unit test for it. The test project carried a verbatim copy of the broken timer as dead, commented-out scaffolding; left in place it invites the pattern back. --- Tests/Xrpl.Tests/Wallet/FundWalletTests.cs | 89 +---------------- Xrpl/Wallet/FundWallet.cs | 109 +++++++-------------- 2 files changed, 39 insertions(+), 159 deletions(-) diff --git a/Tests/Xrpl.Tests/Wallet/FundWalletTests.cs b/Tests/Xrpl.Tests/Wallet/FundWalletTests.cs index 884872db..e354b029 100644 --- a/Tests/Xrpl.Tests/Wallet/FundWalletTests.cs +++ b/Tests/Xrpl.Tests/Wallet/FundWalletTests.cs @@ -1,10 +1,6 @@ -using System; -using System.Diagnostics; -using System.Threading.Tasks; -using System.Timers; +using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; using Xrpl.Client; -using Xrpl.Client.Exceptions; using Xrpl.Wallet; // https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/test/wallet/fundWallet.ts @@ -24,87 +20,4 @@ public async Task TestUFaucetHostsAsync() await WalletSugar.FundWallet(client, wallet); } } - - [TestClass] - public class TestUTimer - { - - private static int attempts = 1; - private static double finalBalance; - private static System.Timers.Timer aTimer; - - private static double _originalBalance; - private static string _address; - private static XrplClient _client; - - private static async void OnTimedEventAsync(Object source, ElapsedEventArgs e) - { - // This piece of code will run after every 1000 ms - if (attempts < 0) - { - finalBalance = _originalBalance; - aTimer.Enabled = false; - } - else - { - attempts -= 1; - } - try - { - double newBalance = 0; - try - { - newBalance = Convert.ToDouble(await _client.GetXrpBalance(_address)); - } - catch (RippleException err) - { - Console.WriteLine(err); - /* newBalance remains undefined */ - } - if (newBalance > _originalBalance) - { - finalBalance = newBalance; - aTimer.Enabled = false; - } - } - catch (Exception err) when (err is RippledException or InvalidCastException) - { - aTimer.Enabled = false; - throw new XRPLFaucetException($"Unable to check if the address {_address} balance has increased.Error: {err.Message}"); - } - } - - public static async Task GetUpdatedBalance( - XrplClient client, - string address, - double originalBalance - ) - { - _client = client; - _address = address; - _originalBalance = originalBalance; - aTimer = new System.Timers.Timer(1000); - aTimer.Elapsed += (sender, e) => OnTimedEventAsync(sender, e); - aTimer.Enabled = true; - aTimer.Start(); - while (aTimer.Enabled) - { - Task.Delay(1000).Wait(); - } - aTimer.Stop(); - return finalBalance; - - } - - //[TestMethod] - public async Task TestTimer() - { - string serverUrl = "wss://s.altnet.rippletest.net:51233"; - XrplClient client = new XrplClient(serverUrl); - await client.Connect(); - XrplWallet wallet = XrplWallet.Generate(); - await GetUpdatedBalance(client, wallet.ClassicAddress, 0); - } - } } - diff --git a/Xrpl/Wallet/FundWallet.cs b/Xrpl/Wallet/FundWallet.cs index 1be20672..f8a7b7b1 100644 --- a/Xrpl/Wallet/FundWallet.cs +++ b/Xrpl/Wallet/FundWallet.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; @@ -57,9 +57,9 @@ public static IDisposable SetTimeout(Action method, int delayInMilliseconds) public static class WalletSugar { //Interval to check an account balance - static int INTERVAL_SECONDS = 1; + const int INTERVAL_SECONDS = 1; //Maximum attempts to retrieve a balance - static int MAX_ATTEMPTS = 20; + const int MAX_ATTEMPTS = 20; public class Funded { @@ -235,23 +235,13 @@ XrplWallet walletToFund { // Check at regular interval if the address is enabled on the XRPL and funded double updatedBalance = await GetUpdatedBalance( - client, - classicAddress, - startingBalance + client, + walletToFund.ClassicAddress, + startingBalance ); if (updatedBalance > startingBalance) { - Funded funded = new Funded( - walletToFund, - Convert.ToDouble( - await GetUpdatedBalance( - client, - walletToFund.ClassicAddress, - startingBalance - ) - ) - ); - return funded; + return new Funded(walletToFund, updatedBalance); } else { @@ -268,75 +258,52 @@ await GetUpdatedBalance( } } - private static int attempts = MAX_ATTEMPTS; - private static double finalBalance; - private static System.Timers.Timer aTimer; - - private static double _originalBalance; - private static string _address; - private static IXrplClient _client; - - private static async void OnTimedEventAsync(Object source, ElapsedEventArgs e) + /// + /// Polls until the funded account's balance rises above , + /// and returns that balance; returns unchanged when the + /// budget of polls runs out. + /// + /// + /// Every piece of state here belongs to the call. The previous implementation drove a + /// through static fields - the poll budget, the address, + /// the balances and the result - which broke it two ways: the budget was never reset, so + /// after roughly twenty polls every later call reported failure without polling at all, + /// and two concurrent calls overwrote each other's address and result, so one wallet's + /// balance could be reported for another. + /// + internal static async Task GetUpdatedBalance( + IXrplClient client, + string address, + double originalBalance + ) { - // This piece of code will run after every 1000 ms - if (attempts < 0) - { - finalBalance = _originalBalance; - aTimer.Enabled = false; - } - else + for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { - attempts -= 1; - } + // The faucet payment needs a ledger to close, so wait before the first read + await Task.Delay(TimeSpan.FromSeconds(INTERVAL_SECONDS)).ConfigureAwait(false); - try - { - double newBalance = 0; + double newBalance; try { - newBalance = Convert.ToDouble(await _client.GetXrpBalance(_address)); + newBalance = Convert.ToDouble(await client.GetXrpBalance(address).ConfigureAwait(false)); } - catch (XrplException err) + catch (XrplException) { - + // The account is not on the ledger yet: the faucet payment has not been validated + continue; } - catch (RippleException err) + catch (RippleException) { - /* newBalance remains undefined */ + continue; } - if (newBalance > _originalBalance) + if (newBalance > originalBalance) { - finalBalance = newBalance; - aTimer.Enabled = false; + return newBalance; } } - catch (Exception err) when (err is RippledException or InvalidCastException) - { - aTimer.Enabled = false; - throw new XRPLFaucetException($"Unable to check if the address {_address} balance has increased.Error: {err.Message}"); - } - } - private static async Task GetUpdatedBalance( - IXrplClient client, - string address, - double originalBalance - ) - { - _client = client; - _address = address; - _originalBalance = originalBalance; - aTimer = new System.Timers.Timer(1000); - aTimer.Elapsed += (sender, e) => OnTimedEventAsync(sender, e); - aTimer.Enabled = true; - aTimer.Start(); - while (aTimer.Enabled) - { - Task.Delay(1000).Wait(); - } - aTimer.Stop(); - return finalBalance; + return originalBalance; } public static string GetFaucetHost(IXrplClient client) From 9f04c2b70558fc845de8d46bd3cba7f88fb4578d Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 10:15:09 -0300 Subject: [PATCH 08/26] test: top an account up when one faucet payout is not enough 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 CreateBroker could never succeed on devnet. EnsureBalanceAsync tops an account up to a stated minimum by calling the faucet again; on the standalone stand the master payment covers any realistic minimum and it returns after the first check. --- Tests/Xrpl.Tests/Integration/README.md | 2 + Tests/Xrpl.Tests/Integration/Utils.cs | 39 +++++++++++++++++++ .../Integration/transactions/TestILoanBase.cs | 4 ++ 3 files changed, 45 insertions(+) diff --git a/Tests/Xrpl.Tests/Integration/README.md b/Tests/Xrpl.Tests/Integration/README.md index 2001ef68..e3c3790c 100644 --- a/Tests/Xrpl.Tests/Integration/README.md +++ b/Tests/Xrpl.Tests/Integration/README.md @@ -41,6 +41,8 @@ XRPL_TEST_NODE_URL=ws://localhost:7016 dotnet test Tests/Xrpl.Tests/Xrpl.Tests.c XRPL_TEST_NODE=devnet dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "FullyQualifiedName~TestIBatchInnerTypes|FullyQualifiedName~TestISponsoredTypes" ``` +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). `IntegrationTestConfig.EnsureBalanceAsync` tops an account up to a stated minimum by calling the faucet again; on the standalone stand the master payment covers any realistic minimum and it returns after the first check. + Amendment-gated classes check the network through `AmendmentGuard` and mark themselves inconclusive when the amendment is not active there, so a broad filter is safe on any profile. The GitHub workflow `devnet-coverage.yml` (manual dispatch) runs the coverage-oriented classes against devnet this way. ## Coverage matrices diff --git a/Tests/Xrpl.Tests/Integration/Utils.cs b/Tests/Xrpl.Tests/Integration/Utils.cs index 97b1f155..a6ba7a8f 100644 --- a/Tests/Xrpl.Tests/Integration/Utils.cs +++ b/Tests/Xrpl.Tests/Integration/Utils.cs @@ -217,6 +217,45 @@ public static async Task TryFundWalletAsync(IXrplClient client, XrplWallet walle } } + /// + /// Tops a wallet up until it holds at least . + /// A public faucet hands out a fixed amount per call (100 XRP on devnet), which is less + /// than some flows need in one account - a lending broker funds a vault and its cover - + /// so the call is repeated instead of assumed to be enough. On the standalone stand the + /// master payment covers any realistic minimum and the loop exits after the first check. + /// + /// Connected XRPL client. + /// Wallet to top up. + /// Balance the wallet must reach. + /// Optional node type override. + public static async Task EnsureBalanceAsync( + IXrplClient client, XrplWallet wallet, decimal minimumXrp, TestNodeType? nodeType = null) + { + const int MaxTopUps = 6; + for (int attempt = 0; ; attempt++) + { + decimal balance; + try + { + balance = await client.GetXrpFreeBalance(wallet.ClassicAddress); + } + catch (Exception) + { + // The account does not exist yet: fund it before reading a balance again + balance = 0m; + } + + if (balance >= minimumXrp) + return; + + if (attempt == MaxTopUps) + throw new InvalidOperationException( + $"Could not fund {wallet.ClassicAddress} up to {minimumXrp} XRP after {MaxTopUps} top-ups (balance {balance} XRP)."); + + await FundWalletAsync(client, wallet, nodeType); + } + } + /// /// Funds multiple wallets, checking balance before each. /// diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs b/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs index 5e2cca4a..311c7572 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestILoanBase.cs @@ -94,6 +94,10 @@ protected static async Task CreateVaultForBroker(IXrplClient client, Xrp /// protected static async Task CreateBroker(IXrplClient client, XrplWallet wallet) { + // The vault deposit (100 XRP) and the broker cover (50 XRP) below exceed a single + // faucet payout, so the account is topped up before anything is spent + await IntegrationTestConfig.EnsureBalanceAsync(client, wallet, 200m); + string vaultId = await CreateVaultForBroker(client, wallet); // Deposit XRP into the vault so the broker has funds to lend From d3f261ea54358931504768b965f69cf4faab9e8a Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 10:15:09 -0300 Subject: [PATCH 09/26] docs(changes): record the faucet fix --- CHANGES.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index d4284945..7a604985 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,12 @@ ## 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 + * **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: @@ -18,6 +24,7 @@ * 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`) * 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 + * 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 From 10ac64fde2d18bd5277a3615bfd2ad8d40ee6427 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 11:27:31 -0300 Subject: [PATCH 10/26] test: generate integration accounts instead of deriving them from a phrase Four classes built their 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. That made the four classes unusable outside the standalone stand. It bit on the stand too. A derived account kept its state between runs, so the five DID tests each created a DID that an earlier run had already created, and the assertion that the DID exists afterwards passed either way. The create path was never actually exercised after the first run. TestIDID folds the generate-and-fund pair into one helper, since it repeated in every test. --- Tests/Xrpl.Tests/Integration/README.md | 2 ++ .../Integration/transactions/TestIBatch.cs | 21 ++++++++----- .../Integration/transactions/TestIClawback.cs | 9 ++++-- .../Integration/transactions/TestIDID.cs | 30 ++++++++++++------- .../transactions/TestIMultisign.cs | 13 +++++--- 5 files changed, 51 insertions(+), 24 deletions(-) diff --git a/Tests/Xrpl.Tests/Integration/README.md b/Tests/Xrpl.Tests/Integration/README.md index e3c3790c..ec83ca89 100644 --- a/Tests/Xrpl.Tests/Integration/README.md +++ b/Tests/Xrpl.Tests/Integration/README.md @@ -41,6 +41,8 @@ XRPL_TEST_NODE_URL=ws://localhost:7016 dotnet test Tests/Xrpl.Tests/Xrpl.Tests.c XRPL_TEST_NODE=devnet dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "FullyQualifiedName~TestIBatchInnerTypes|FullyQualifiedName~TestISponsoredTypes" ``` +Accounts are generated per run, never derived from a fixed phrase. 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, and two classes disable a master key on one of theirs. On the standalone stand a derived account also carried state between runs, which let a "create" test pass as a modify. + 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). `IntegrationTestConfig.EnsureBalanceAsync` tops an account up to a stated minimum by calling the faucet again; on the standalone stand the master payment covers any realistic minimum and it returns after the first check. Amendment-gated classes check the network through `AmendmentGuard` and mark themselves inconclusive when the amendment is not active there, so a broad filter is safe on any profile. The GitHub workflow `devnet-coverage.yml` (manual dispatch) runs the coverage-oriented classes against devnet this way. diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIBatch.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIBatch.cs index e1f3b963..960e9e40 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIBatch.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIBatch.cs @@ -28,14 +28,19 @@ public class TestIBatch public TestContext TestContext { get; set; } public static SetupIntegration runner; - static XrplWallet walletPrimary = XrplWallet.FromNormalizedText("primary test account"); - static XrplWallet walletSecondary_1 = XrplWallet.FromNormalizedText("secondary test account 1"); - static XrplWallet walletSecondary_2 = XrplWallet.FromNormalizedText("secondary test account 2"); - static XrplWallet walletMultiSign = XrplWallet.FromNormalizedText("multi sign test account"); - static XrplWallet walletMultiSigner_1 = XrplWallet.FromNormalizedText("multi sign test account 1"); - static XrplWallet walletMultiSigner_2 = XrplWallet.FromNormalizedText("multi sign test account 2"); - static XrplWallet walletRegularKey = XrplWallet.FromNormalizedText("regular key test account"); - static XrplWallet walletRegularKey_signer = XrplWallet.FromNormalizedText("regular key test account signer"); + // Generated per run, never derived from a fixed phrase. A phrase is the same account on + // every network, so on a public one it is shared with everyone who ever ran this test: + // the state it starts from is whatever they left, and the setup below disables a master + // key on it. On the standalone stand a derived account also carried state between runs, + // which let a "create" test quietly pass as a modify. + static XrplWallet walletPrimary = XrplWallet.Generate(); + static XrplWallet walletSecondary_1 = XrplWallet.Generate(); + static XrplWallet walletSecondary_2 = XrplWallet.Generate(); + static XrplWallet walletMultiSign = XrplWallet.Generate(); + static XrplWallet walletMultiSigner_1 = XrplWallet.Generate(); + static XrplWallet walletMultiSigner_2 = XrplWallet.Generate(); + static XrplWallet walletRegularKey = XrplWallet.Generate(); + static XrplWallet walletRegularKey_signer = XrplWallet.Generate(); [ClassInitialize] public static async Task MyClassInitializeAsync(TestContext testContext) diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIClawback.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIClawback.cs index 320b2655..3642770b 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIClawback.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIClawback.cs @@ -30,8 +30,13 @@ public class TestIClawback public TestContext TestContext { get; set; } public static IXrplClient client; - static XrplWallet walletIssuer = XrplWallet.FromNormalizedText("clawback issuer account test"); - static XrplWallet walletHolder = XrplWallet.FromNormalizedText("clawback holder account test"); + // Generated per run, never derived from a fixed phrase. A phrase is the same account on + // every network, so on a public one it is shared with everyone who ever ran this test: + // the state it starts from is whatever they left, and the setup below disables a master + // key on it. On the standalone stand a derived account also carried state between runs, + // which let a "create" test quietly pass as a modify. + static XrplWallet walletIssuer = XrplWallet.Generate(); + static XrplWallet walletHolder = XrplWallet.Generate(); const string CurrencyCode = "CLW"; static bool issuerInitialized = false; diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIDID.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIDID.cs index 0f7df3ce..75e4d248 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIDID.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIDID.cs @@ -97,6 +97,21 @@ private async Task VerifyDIDExists(string account) #endregion + /// + /// A fresh, funded account for one test. + /// Generated per run, never derived from a fixed phrase. A phrase is the same account on + /// every network, so on a public one it is shared with everyone who ever ran this test: + /// the state it starts from is whatever they left, and the setup below disables a master + /// key on it. On the standalone stand a derived account also carried state between runs, + /// which let a "create" test quietly pass as a modify. + /// + private static async Task NewFundedWalletAsync() + { + XrplWallet wallet = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletAsync(client, wallet, nodeType); + return wallet; + } + #region DIDSet Tests /// @@ -105,8 +120,7 @@ private async Task VerifyDIDExists(string account) [TestMethod] public async Task TestDIDSet_CreateWithData() { - var wallet = XrplWallet.FromNormalizedText("did create with data test"); - await IntegrationTestConfig.TryFundWalletAsync(client, wallet, nodeType); + XrplWallet wallet = await NewFundedWalletAsync(); var didSet = new DIDSet { @@ -134,8 +148,7 @@ public async Task TestDIDSet_CreateWithData() [TestMethod] public async Task TestDIDSet_CreateWithAllFields() { - var wallet = XrplWallet.FromNormalizedText("did all fields test"); - await IntegrationTestConfig.TryFundWalletAsync(client, wallet, nodeType); + XrplWallet wallet = await NewFundedWalletAsync(); var didSet = new DIDSet { @@ -165,8 +178,7 @@ public async Task TestDIDSet_CreateWithAllFields() [TestMethod] public async Task TestDIDSet_UpdateExisting() { - var wallet = XrplWallet.FromNormalizedText("did update test"); - await IntegrationTestConfig.TryFundWalletAsync(client, wallet, nodeType); + XrplWallet wallet = await NewFundedWalletAsync(); var createDID = new DIDSet { @@ -216,8 +228,7 @@ public async Task TestDIDSet_UpdateExisting() [TestMethod] public async Task TestDIDDelete_DeleteExisting() { - var wallet = XrplWallet.FromNormalizedText("did delete test"); - await IntegrationTestConfig.TryFundWalletAsync(client, wallet, nodeType); + XrplWallet wallet = await NewFundedWalletAsync(); var createDID = new DIDSet { @@ -267,8 +278,7 @@ public async Task TestDIDDelete_DeleteExisting() [TestMethod] public async Task TestDID_FullLifecycle() { - var wallet = XrplWallet.FromNormalizedText("did lifecycle test"); - await IntegrationTestConfig.TryFundWalletAsync(client, wallet, nodeType); + XrplWallet wallet = await NewFundedWalletAsync(); Console.WriteLine($"Starting DID lifecycle test for: {wallet.ClassicAddress}"); diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIMultisign.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIMultisign.cs index c81bf342..0980b9b8 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIMultisign.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIMultisign.cs @@ -22,10 +22,15 @@ public class TestIMultisign public TestContext TestContext { get; set; } public static SetupIntegration runner; - static XrplWallet walletMultiSign = XrplWallet.FromNormalizedText("multisign payment test account"); - static XrplWallet walletMultiSigner_1 = XrplWallet.FromNormalizedText("multisign payment signer 1"); - static XrplWallet walletMultiSigner_2 = XrplWallet.FromNormalizedText("multisign payment signer 2"); - static XrplWallet walletDestination = XrplWallet.FromNormalizedText("multisign payment destination"); + // Generated per run, never derived from a fixed phrase. A phrase is the same account on + // every network, so on a public one it is shared with everyone who ever ran this test: + // the state it starts from is whatever they left, and the setup below disables a master + // key on it. On the standalone stand a derived account also carried state between runs, + // which let a "create" test quietly pass as a modify. + static XrplWallet walletMultiSign = XrplWallet.Generate(); + static XrplWallet walletMultiSigner_1 = XrplWallet.Generate(); + static XrplWallet walletMultiSigner_2 = XrplWallet.Generate(); + static XrplWallet walletDestination = XrplWallet.Generate(); [ClassInitialize] public static async Task MyClassInitializeAsync(TestContext testContext) From 7bdf39fb17f89fd19464d8f858c681d5051d9e8e Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 11:27:32 -0300 Subject: [PATCH 11/26] test(batch): poll for the inner transaction instead of asking once Reading an inner batch transaction back by its computed id is a poll, not a single call. The outer Batch is validated by the time the lookup runs and its inners were applied in the same ledger, but the node answers txnNotFound for a short window before they are queryable, which made the class fail intermittently under a parallel load. Exhausting the budget now says which inner never appeared and what the two explanations are. --- .../transactions/TestIBatchInnerTypes.cs | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIBatchInnerTypes.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIBatchInnerTypes.cs index e98492af..b1b2b4ed 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIBatchInnerTypes.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIBatchInnerTypes.cs @@ -150,12 +150,43 @@ private static async Task> SubmitBatchAsync(Batch batch, XrplW { JsonObject inner = wrapper["RawTransaction"].AsObject(); string hash = inner.ComputeInnerTxId().ToUpperInvariant(); - TransactionResponse innerTx = await client.TxV1(new TxRequest(hash)).Typed(); + TransactionResponse innerTx = await LookUpInnerAsync(hash, inner["TransactionType"].GetValue()); inners.Add(new InnerResult(hash, inner["TransactionType"].GetValue(), innerTx.Meta?.TransactionResult, innerTx.Meta)); } return inners; } + /// + /// Reads one inner transaction back by the id computed from the signed blob. + /// + /// + /// Reading a just-validated transaction is a poll, not a single call. The outer Batch is + /// validated by the time this runs 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 turned that into an intermittent failure. + /// + private static async Task LookUpInnerAsync(string hash, string transactionType) + { + const int MaxAttempts = 10; + for (int attempt = 1; ; attempt++) + { + try + { + return await client.TxV1(new TxRequest(hash)).Typed(); + } + catch (RippledException ex) when (attempt < MaxAttempts && ex.Message.Contains("txnNotFound")) + { + await Task.Delay(TimeSpan.FromSeconds(2)); + } + catch (RippledException ex) when (ex.Message.Contains("txnNotFound")) + { + throw new AssertFailedException( + $"the inner {transactionType} {hash} never appeared in the ledger after {MaxAttempts} lookups; " + + "either it was not applied or the id computed from the signed blob does not match the node's", ex); + } + } + } + private static void AssertAllInnersSucceeded(IReadOnlyList inners) { foreach (InnerResult inner in inners) From 0d82f1b820bd2ae254d58d6974bc9598ae453d76 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 11:27:32 -0300 Subject: [PATCH 12/26] docs(changes): record the account and lookup fixes --- CHANGES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 7a604985..258a38ce 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -24,6 +24,8 @@ * 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`) * 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 + * 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 From bd84d80106a369bbd44cdf650d13125a0b295ad8 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 12:20:58 -0300 Subject: [PATCH 13/26] test(batch): assert on the result the ledger recorded, not the engine result The 18 Batch tests checked the engine result of their submission. That is provisional: it says what one node made of the transaction against its open ledger, not what the network settled on, and they also 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 that did not yet carry it and got tefPAST_SEQ. That is how this surfaced when the class was first run against devnet. The unused TransactionSummary overload goes with it. --- .../Integration/transactions/TestIBatch.cs | 89 ++++++++++++++----- 1 file changed, 65 insertions(+), 24 deletions(-) diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIBatch.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIBatch.cs index 960e9e40..15bc8ab7 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIBatch.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIBatch.cs @@ -13,6 +13,7 @@ using Xrpl.Models.Transactions; using Xrpl.Models.Utils; using Xrpl.Sugar; +using Xrpl.Utils.Hashes; using Xrpl.Wallet; using Xrpl.Client; @@ -92,7 +93,7 @@ public async Task TestBatchSingleMultiSignV1() // #v1 var res = await runner.client.SubmitMulti(tx, new List() { walletMultiSigner_1, walletMultiSigner_2 }, true); - ValidateResult(res); + await ValidateResultAsync(res); } [TestMethod] @@ -105,7 +106,7 @@ public async Task TestBatchSingleMultiSignV2() var sig2 = walletMultiSigner_2.Sign(stx1, true); var stx2 = sig2.GetTx(); var res = await runner.client.SubmitRequest(sig2.TxBlob, true); - ValidateResult(res); + await ValidateResultAsync(res); } [TestMethod] @@ -118,7 +119,7 @@ public async Task TestBatchSingleMultiSignV3() var sig2 = walletMultiSigner_2.Sign(stx1, true); var singed = Signer.Multisign([sig1.TxBlob, sig2.TxBlob]); var res = await runner.client.SubmitRequest(singed, true); - ValidateResult(res); + await ValidateResultAsync(res); } private static async Task GetTxForSingleMultiSign() @@ -183,7 +184,7 @@ public async Task TestBatchMultiAccounts_V1() //#v1 var res = await runner.client.SubmitMultiBatch(batch, new[] { walletPrimary, walletSecondary_1, walletSecondary_2 }, true); - ValidateResult(res); + await ValidateResultAsync(res); } [TestMethod] @@ -197,7 +198,7 @@ public async Task TestBatchMultiAccounts_V2_1() var combined = XrplWallet.CombineBatchSigners(new[] { sig1.TxBlob, sig2.TxBlob}); var sig3 = walletPrimary.Sign(combined.GetTx()); var res = await runner.client.SubmitRequest(sig3.TxBlob, true); - ValidateResult(res); + await ValidateResultAsync(res); } [TestMethod] @@ -211,7 +212,7 @@ public async Task TestBatchMultiAccounts_V2_2() var sig3 = walletSecondary_2.Sign(batch); var combined = XrplWallet.CombineBatchSigners(new[] { sig1.TxBlob, sig2.TxBlob,sig3.TxBlob }); var res = await runner.client.SubmitRequest(combined.TxBlob, true); - ValidateResult(res); + await ValidateResultAsync(res); } [TestMethod] @@ -224,7 +225,7 @@ public async Task TestBatchMultiAccounts_V3() var sig2 = walletSecondary_2.Sign(sig1.GetTx()); var sig3 = walletPrimary.Sign(sig2.GetTx()); var res = await runner.client.SubmitRequest(sig3.TxBlob, true); - ValidateResult(res); + await ValidateResultAsync(res); } [TestMethod] @@ -238,7 +239,7 @@ public async Task TestBatchMultiAccounts_V3_2() var sig3 = walletSecondary_1.Sign(sig2.GetTx()); var tx = sig3.GetTxDictionary(); var res = await runner.client.SubmitRequest(sig3.TxBlob, true); - ValidateResult(res); + await ValidateResultAsync(res); } private static async Task GetTxForBatchMultiAccounts() @@ -295,7 +296,7 @@ public async Task TestBatchMultiAccountsWithTopMultiSign_V1() var res = await runner.client.SubmitMultiBatch(batch, new[] { w1, w2, owner, signer1, signer2 }, true); - ValidateResult(res); + await ValidateResultAsync(res); } [TestMethod] @@ -309,7 +310,7 @@ public async Task TestBatchMultiAccountsWithTopMultiSign_V2() var combined = XrplWallet.CombineBatchSigners(sig1.TxBlob,sig2.TxBlob,sig3.TxBlob,sig4.TxBlob); var res = await runner.client.SubmitRequest(combined.TxBlob, true); - ValidateResult(res); + await ValidateResultAsync(res); } [TestMethod] @@ -322,7 +323,7 @@ public async Task TestBatchMultiAccountsWithTopMultiSign_V3_1() var sig2 = w2.Sign(sig1.GetTx()); var res = await runner.client.SubmitRequest(sig2.TxBlob, true); - ValidateResult(res); + await ValidateResultAsync(res); } [TestMethod] @@ -335,7 +336,7 @@ public async Task TestBatchMultiAccountsWithTopMultiSign_V3_2() var sig4 = signer2.Sign(sig3.GetTx(), true); var res = await runner.client.SubmitRequest(sig4.TxBlob, true); - ValidateResult(res); + await ValidateResultAsync(res); } private static async Task<(XrplWallet owner, XrplWallet signer1, XrplWallet signer2, XrplWallet w1, XrplWallet w2, Batch batch)> GetMultiAccountBatchWithTopMultiSign() @@ -396,7 +397,7 @@ public async Task TestBatchMultiAccountsWithInnerMultiSignV1() var (owner, signer1, signer2, w1, w2, batch) = await GetBatchMultiAccountsWithInnerMultiSign(); var res = await runner.client.SubmitMultiBatch(batch, new[] { w1, w2, owner, signer1, signer2 }, true); - ValidateResult(res); + await ValidateResultAsync(res); } @@ -410,7 +411,7 @@ public async Task TestBatchMultiAccountsWithInnerMultiSignV2() var sig4 = signer2.Sign(batch, true, owner.ClassicAddress); var combined = XrplWallet.CombineBatchSigners(sig1.TxBlob, sig2.TxBlob, sig3.TxBlob, sig4.TxBlob); var res = await runner.client.SubmitRequest(combined.TxBlob, true); - ValidateResult(res); + await ValidateResultAsync(res); } [TestMethod] @@ -423,7 +424,7 @@ public async Task TestBatchMultiAccountsWithInnerMultiSignV3_1() var sig2 = w2.Sign(sig1.GetTx()); var json = sig2.GetTx().ToJson(); var res = await runner.client.SubmitRequest(sig2.TxBlob, true); - ValidateResult(res); + await ValidateResultAsync(res); } [TestMethod] @@ -436,7 +437,7 @@ public async Task TestBatchMultiAccountsWithInnerMultiSignV3_2() var sig4 = signer2.Sign(sig3.GetTx(), true, owner.ClassicAddress); var res = await runner.client.SubmitRequest(sig4.TxBlob, true); - ValidateResult(res); + await ValidateResultAsync(res); } @@ -504,7 +505,7 @@ public async Task TestBatchWithDuplicateSignatures_AreDeduplicated() var combined = XrplWallet.CombineBatchSigners(new[] { sig1.TxBlob, sig2.TxBlob, sig3.TxBlob, sig1_dup.TxBlob, sig2_dup.TxBlob }); var res = await runner.client.SubmitRequest(combined.TxBlob, true); - ValidateResult(res); + await ValidateResultAsync(res); } [TestMethod] @@ -521,7 +522,7 @@ public async Task TestBatchMultiSign_WithDuplicateSignatures_AreDeduplicated() var combined = XrplWallet.CombineBatchSigners(sig1.TxBlob, sig2.TxBlob, sig3.TxBlob, sig4.TxBlob, sig3_dup.TxBlob); var res = await runner.client.SubmitRequest(combined.TxBlob, true); - ValidateResult(res); + await ValidateResultAsync(res); } #endregion @@ -541,23 +542,63 @@ await Helper.ThrowsExceptionAsync(async () => #endregion - private static void ValidateResult(Submit res) + /// + /// Checks the engine result, then waits until the transaction is in a ledger and checks the + /// result the ledger recorded. + /// + /// + /// An engine result is provisional: it says what one node made of the transaction against + /// its open ledger, not what the network settled on, and terQUEUED says it has not + /// been applied at all. Asserting on it alone let these tests pass on a batch that never + /// reached a ledger, and it left the account sequences unsettled - the next test autofilled + /// against a ledger that did not yet carry the previous submission and got + /// tefPAST_SEQ, which is how this surfaced when the class was first run against + /// devnet instead of the standalone stand. + /// + private static async Task ValidateResultAsync(Submit res) { if (res is not { EngineResult: "tesSUCCESS" or "terQUEUED" }) { throw new RippleException($"Invalid result, {res.EngineResult}"); } - } - private void ValidateResult(TransactionSummary res) - { - if (res is not { Meta: { TransactionResult: "tesSUCCESS" or "terQUEUED" } }) + string hash = HashLedger.HashSignedTx(res.TxBlob); + const int MaxAttempts = 15; + for (int attempt = 1; ; attempt++) { - throw new RippleException($"Invalid result, {res.Meta.TransactionResult}"); + string ledgerResult = null; + try + { + // Metadata is written when the transaction is applied in a ledger, so its + // presence is what separates a settled result from the provisional one above + TransactionResponse tx = await runner.client.TxV1(new TxRequest(hash)).Typed(); + ledgerResult = tx.Meta?.TransactionResult; + } + catch (RippledException ex) when (ex.Message.Contains("txnNotFound")) + { + // Submitted, not yet in a ledger this node will answer for + } + + if (ledgerResult is not null) + { + if (ledgerResult != "tesSUCCESS") + { + throw new RippleException($"Invalid result in the ledger, {ledgerResult}"); + } + return; + } + + if (attempt == MaxAttempts) + { + throw new RippleException($"Transaction {hash} did not reach a ledger after {MaxAttempts} lookups."); + } + + await Task.Delay(TimeSpan.FromSeconds(2)); } } + #region Signer From 97de08a4cedbb024ff7d8244f21e2651b893785e Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 12:20:59 -0300 Subject: [PATCH 14/26] test(batch): clear the time gate instead of reaching it 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 case waited that way and its EscrowCancel inner came in one close time short. Under tfAllOrNothing that reverts the whole batch, so the sibling EscrowFinish was never committed either and the lookup for it failed with no sign of the real cause. Standalone close times move in coarse steps and land on equality readily; devnet's next step arrived within seconds and hid it. The settle batch is tfIndependent now, where each inner records its own result, and a missing inner says what tfAllOrNothing does to a batch with a failing sibling: rippled discards the whole view, no inner is committed, the failing inner's result is recorded nowhere, and the outer Batch still validates with tesSUCCESS. --- .../transactions/TestIBatchInnerTypes.cs | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIBatchInnerTypes.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIBatchInnerTypes.cs index b1b2b4ed..a268f243 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIBatchInnerTypes.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIBatchInnerTypes.cs @@ -101,9 +101,18 @@ private static async Task ValidatedCloseTimeAsync() return entity.CloseTime ?? throw new InvalidOperationException("validated ledger has no close_time"); } + /// + /// Waits until the validated close time is strictly past . + /// + /// + /// Strictly: rippled's time gates are now > mark (after() in View.cpp), so a + /// close time equal to the mark is still too early. Standalone close times move in coarse + /// steps and land on equality readily, which is where this was found; on devnet the next + /// step came within seconds and hid it. + /// private static async Task WaitForCloseTimeAsync(DateTime target) { - while (await ValidatedCloseTimeAsync() < target) + while (await ValidatedCloseTimeAsync() <= target) { await Task.Delay(TimeSpan.FromSeconds(3)); } @@ -181,8 +190,12 @@ private static async Task LookUpInnerAsync(string hash, str catch (RippledException ex) when (ex.Message.Contains("txnNotFound")) { throw new AssertFailedException( - $"the inner {transactionType} {hash} never appeared in the ledger after {MaxAttempts} lookups; " + - "either it was not applied or the id computed from the signed blob does not match the node's", ex); + $"the inner {transactionType} {hash} never appeared in the ledger after {MaxAttempts} lookups. " + + "Under tfAllOrNothing that is what a failing sibling looks like: rippled discards the whole " + + "batch view (apply.cpp), so no inner is committed, while the outer Batch still validates with " + + "tesSUCCESS and the failing inner's own result is recorded nowhere. Re-run the case under " + + "tfIndependent to see which inner failed and why. The other explanation is an id computed " + + "from the signed blob that does not match the node's.", ex); } } } @@ -349,7 +362,10 @@ public async Task Batch_Inner_EscrowCreate_EscrowFinish_EscrowCancel() await WaitForCloseTimeAsync(closeTime + EscrowCancelMargin); - Batch settle = NewBatch(owner, BatchFlags.tfAllOrNothing, + // tfIndependent, not tfAllOrNothing: both inners are time-gated, and under + // tfAllOrNothing one of them being a tick early reverts the batch and leaves neither + // recorded, so the failure says nothing about which gate was missed + Batch settle = NewBatch(owner, BatchFlags.tfIndependent, new EscrowFinish { Account = owner.ClassicAddress, Owner = owner.ClassicAddress, OfferSequence = outerSequence + 1 }.ToBatchTx(), new EscrowCancel { Account = owner.ClassicAddress, Owner = owner.ClassicAddress, OfferSequence = outerSequence + 2 }.ToBatchTx()); AssertAllInnersSucceeded(await SubmitBatchAsync(settle, owner)); From 58c690064b028c39ad3f2172c60753f3dd9ae26c Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 12:20:59 -0300 Subject: [PATCH 15/26] docs(changes): record the batch assertion and time-gate findings --- CHANGES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 258a38ce..084774ca 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -22,9 +22,12 @@ * **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 + * **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 From 5ac1755c57209001414bd0dd82987d8cec9e0f37 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 14:12:48 -0300 Subject: [PATCH 16/26] fix(json): stop refusing Oracle values the ledger legitimately holds 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 it 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 by running the suite against devnet, where other people's oracles exist; the standalone stand only ever holds ours. Reading is total now. Bytes that are text are decoded, 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, alongside one for a nonstandard currency code. --- .../Json/Converters/OracleConvertersTests.cs | 33 ++++++++++++- .../Json/Converters/OracleConverters.cs | 49 ++++++++++++++----- 2 files changed, 68 insertions(+), 14 deletions(-) diff --git a/Tests/Xrpl.Tests/Client/Json/Converters/OracleConvertersTests.cs b/Tests/Xrpl.Tests/Client/Json/Converters/OracleConvertersTests.cs index 31b903c6..09578766 100644 --- a/Tests/Xrpl.Tests/Client/Json/Converters/OracleConvertersTests.cs +++ b/Tests/Xrpl.Tests/Client/Json/Converters/OracleConvertersTests.cs @@ -1,6 +1,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using System; +using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; @@ -96,6 +97,19 @@ public void Read_HexCode40Chars_DecodesToString() Assert.AreEqual("USD", result.Currency); } + /// + /// A currency code is 160 bits the ledger does not constrain, so a nonstandard one that is + /// not text comes back as the hex the node sent rather than throwing out of the converter. + /// + [TestMethod] + public void Read_HexCode40Chars_NonPrintable_ReturnsHexUnchanged() + { + string hex = "0158415500000000C1F76FF6ECB0BAC600000000"; // legacy demurrage code + string json = $"{{\"Currency\": \"{hex}\"}}"; + Model result = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + Assert.AreEqual(hex, result.Currency); + } + [TestMethod] public void Read_Null_ReturnsNull() { @@ -167,11 +181,26 @@ public void Read_HexEncoded_DecodesToAscii() Assert.AreEqual("test", result.Provider); } + /// + /// Provider, AssetClass and URI are Blob fields; rippled checks their length and nothing + /// else (OracleSet::preflight), so an oracle published by anyone may hold arbitrary bytes. + /// Refusing one threw out of the converter and took the whole response with it: a single + /// third-party oracle made a ledger_data page from devnet unreadable. + /// [TestMethod] - public void Read_HexDecodedNonPrintable_ThrowsJsonException() + public void Read_HexDecodedNonPrintable_ReturnsHexUnchanged() { string json = "{\"Provider\": \"01\"}"; - Helper.ThrowsException(() => JsonSerializer.Deserialize(json, XrplJsonOptions.Default)); + Model result = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + Assert.AreEqual("01", result.Provider); + } + + [TestMethod] + public void Read_NonAsciiPlainText_ReturnsAsIs() + { + string json = JsonSerializer.Serialize(new Dictionary { ["Provider"] = "\u0001\u0002x" }); + Model result = JsonSerializer.Deserialize(json, XrplJsonOptions.Default); + Assert.AreEqual("\u0001\u0002x", result.Provider); } [TestMethod] diff --git a/Xrpl/Client/Json/Converters/OracleConverters.cs b/Xrpl/Client/Json/Converters/OracleConverters.cs index 321784e2..d467f4c3 100644 --- a/Xrpl/Client/Json/Converters/OracleConverters.cs +++ b/Xrpl/Client/Json/Converters/OracleConverters.cs @@ -36,6 +36,22 @@ internal static void ValidatePrintableAsciiBytes(ReadOnlySpan bytes, strin } } + /// + /// Whether every byte is printable ASCII, which is what a value the ledger holds as text + /// looks like. A value that is not is still legitimate: rippled checks the length of + /// Provider, AssetClass and URI and nothing else (OracleSet::preflight), so they are + /// Blob fields carrying arbitrary bytes. + /// + internal static bool IsPrintableAscii(ReadOnlySpan bytes) + { + for (int i = 0; i < bytes.Length; i++) + { + if (bytes[i] < 0x20 || bytes[i] > 0x7E) + return false; + } + return true; + } + internal static bool IsHexString(ReadOnlySpan value) { for (int i = 0; i < value.Length; i++) @@ -137,7 +153,6 @@ public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonS // Standard currency code (exactly three characters), per XRPL Currency type JSON display rules. if (value.Length == 3) { - OracleAsciiValidation.ValidatePrintableAsciiChars(value.AsSpan(), "Oracle currency code"); return value; } @@ -147,12 +162,9 @@ public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonS return DecodeOracleCurrency(value); } - OracleAsciiValidation.ValidatePrintableAsciiChars(value.AsSpan(), "Oracle currency code"); - if (value.Length > 20) - { - throw new JsonException("Oracle currency plain text must be at most 20 ASCII characters."); - } - + // Anything else is handed back as it arrived. A currency code is 160 bits the + // ledger does not constrain, so what a third party published is not ours to refuse + // on the way in; the write path still holds callers to a code it can encode. return value; } @@ -196,8 +208,9 @@ private static string DecodeOracleCurrency(string hex) for (int i = 0; i < 20 && bytes[i] != 0; i++) length++; - OracleAsciiValidation.ValidatePrintableAsciiBytes(bytes.AsSpan(0, length), "Oracle currency code"); - return Encoding.ASCII.GetString(bytes, 0, length); + return OracleAsciiValidation.IsPrintableAscii(bytes.AsSpan(0, length)) + ? Encoding.ASCII.GetString(bytes, 0, length) + : hex; } } @@ -227,7 +240,6 @@ public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonS if (value.Length % 2 == 0 && OracleAsciiValidation.IsHexString(value.AsSpan())) return DecodeHexString(value); - OracleAsciiValidation.ValidatePrintableAsciiChars(value.AsSpan(), "Oracle hex string"); return value; } @@ -249,6 +261,18 @@ public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOp writer.WriteStringValue(Convert.ToHexString(bytes)); } + /// + /// Decodes the hex to text when it is printable ASCII, and otherwise hands back the hex + /// the node sent. + /// + /// + /// Reading is total on purpose. These are Blob fields whose bytes rippled does not + /// constrain, so an oracle published by anyone else may hold whatever it likes; refusing + /// such a value threw out of the converter and took the whole response with it, which is + /// how this was found - one third-party oracle in a ledger_data page from devnet + /// made the page unreadable. Writing still requires text, so a value that came back as + /// hex is not something to send straight back. + /// private static string DecodeHexString(string hex) { byte[] bytes = new byte[hex.Length / 2]; @@ -257,8 +281,9 @@ private static string DecodeHexString(string hex) bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); } - OracleAsciiValidation.ValidatePrintableAsciiBytes(bytes, "Oracle hex string"); - return Encoding.ASCII.GetString(bytes); + return OracleAsciiValidation.IsPrintableAscii(bytes) + ? Encoding.ASCII.GetString(bytes) + : hex; } } } From 6992695fb0179413fa2325c42f1ed2abf9b29a2a Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 14:12:48 -0300 Subject: [PATCH 17/26] test: make Utils.TestTransaction verify what it claimed to The helper behind about forty of the older integration tests looked the submitted transaction up once, immediately after submission, and 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. Two classes 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 two check tests carried a 60s budget sized for a stand where funding is a local payment and the test forces the ledger to close. Raised for a public network, where funding comes from a serialised faucet and the ledger closes on its own. --- .../Integration/TestIAdminCredentials.cs | 17 +++++++ Tests/Xrpl.Tests/Integration/Utils.cs | 44 ++++++++++++++++++- .../Integration/transactions/accountDelete.cs | 15 +++++++ .../Integration/transactions/checkCancel.cs | 5 ++- .../Integration/transactions/checkCash.cs | 5 ++- 5 files changed, 82 insertions(+), 4 deletions(-) diff --git a/Tests/Xrpl.Tests/Integration/TestIAdminCredentials.cs b/Tests/Xrpl.Tests/Integration/TestIAdminCredentials.cs index 66c3eb43..6d026064 100644 --- a/Tests/Xrpl.Tests/Integration/TestIAdminCredentials.cs +++ b/Tests/Xrpl.Tests/Integration/TestIAdminCredentials.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Xrpl.Client; +using XrplTests.Xrpl.ClientLib.Integration; using Xrpl.Client.Exceptions; using XrplTests; @@ -51,6 +52,22 @@ private static XrplClient CreateClient(bool withCredentials) return new XrplClient(ServerUrl, options); } + /// + /// The credentials under test are configured in the stand's own rippled.cfg, and the + /// port that checks them exists only there, so the class has nothing to say about a + /// public network. + /// + [TestInitialize] + public void RequireStandaloneStand() + { + if (!IntegrationTestConfig.IsStandalone()) + { + Assert.Inconclusive( + "The admin-auth port is a standalone-stand configuration ([port_ws_admin_auth] in .ci-config/rippled.cfg); " + + "it does not exist on a public network."); + } + } + [TestMethod] public async Task TestAdminCommandIsRejectedWithoutCredentials() { diff --git a/Tests/Xrpl.Tests/Integration/Utils.cs b/Tests/Xrpl.Tests/Integration/Utils.cs index a6ba7a8f..2a58a0ae 100644 --- a/Tests/Xrpl.Tests/Integration/Utils.cs +++ b/Tests/Xrpl.Tests/Integration/Utils.cs @@ -455,6 +455,18 @@ public static async Task GenerateFundedWallet(IXrplClient client) return wallet; } + /// + /// Waits until the transaction is in a ledger and checks the result recorded there. + /// + /// + /// This used to look the transaction up once, immediately after submission, and ignore + /// what came back. On the standalone stand the caller had just forced a ledger close, so + /// the lookup happened to succeed; on any network where ledgers close on their own it + /// raced, and nothing was asserted about the result either way. It is a poll now, and the + /// result the ledger recorded is checked - which is what the callers of + /// + /// were assumed to be verifying all along. + /// public static async Task VerifySubmittedTransaction(IXrplClient client, object tx, string? hashTx = null) { string hash; @@ -467,8 +479,36 @@ public static async Task VerifySubmittedTransaction(IXrplClient client, object t else hash = HashLedger.HashSignedTx(JsonNode.Parse( JsonSerializer.Serialize(tx, global::Xrpl.Client.Json.XrplJsonOptions.Default))); - TxRequest request = new TxRequest(hash); - TransactionResponse data = await client.TxV1(request).Typed(); + + const int MaxAttempts = 20; + for (int attempt = 1; ; attempt++) + { + string ledgerResult = null; + try + { + // Metadata is written when the transaction is applied in a ledger, so its + // presence is what separates a settled result from a provisional one + TransactionResponse data = await client.TxV1(new TxRequest(hash)).Typed(); + ledgerResult = data.Meta?.TransactionResult; + } + catch (RippledException error) when (error.Message.Contains("txnNotFound")) + { + // Submitted, not yet in a ledger this node will answer for + } + + if (ledgerResult is not null) + { + Assert.AreEqual("tesSUCCESS", ledgerResult, $"transaction {hash} was recorded with {ledgerResult}"); + return; + } + + if (attempt == MaxAttempts) + { + Assert.Fail($"transaction {hash} did not reach a ledger after {MaxAttempts} lookups."); + } + + await Task.Delay(TimeSpan.FromSeconds(2)); + } } public static async Task TestTransaction(IXrplClient client, Dictionary transaction, XrplWallet wallet) diff --git a/Tests/Xrpl.Tests/Integration/transactions/accountDelete.cs b/Tests/Xrpl.Tests/Integration/transactions/accountDelete.cs index a24a1240..3879a043 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/accountDelete.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/accountDelete.cs @@ -27,6 +27,21 @@ public static async Task MyClassInitializeAsync(TestContext testContext) runner = await new SetupIntegration().SetupClient(ServerUrl.serverUrl); } + /// + /// rippled refuses to delete an account until 256 ledgers have passed since it was + /// created (tecTOO_SOON). The test forces those closes with ledger_accept, which only a + /// standalone stand permits; on a public network it would have to wait them out in real + /// time, and that is rippled's rule under test rather than anything about the SDK. + /// + [TestInitialize] + public void RequireStandaloneStand() + { + if (!IntegrationTestConfig.IsStandalone()) + { + Assert.Inconclusive("Deleting an account needs 256 forced ledger closes, which only the standalone stand allows."); + } + } + [TestMethod] public async Task TestRequestMethod() { diff --git a/Tests/Xrpl.Tests/Integration/transactions/checkCancel.cs b/Tests/Xrpl.Tests/Integration/transactions/checkCancel.cs index e2a82c0e..fc526d6a 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/checkCancel.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/checkCancel.cs @@ -20,7 +20,10 @@ public class TestICheckCancel private static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; [TestMethod] - [Timeout(60000)] + // Sized for a public network: funding comes from a faucet whose calls are serialised + // across the run, and the transaction has to wait for a ledger to close on its own + // rather than one the test forces. On the standalone stand this is never approached. + [Timeout(300000)] public async Task TestRequestMethod() { IXrplClient client = await IntegrationTestConfig.CreateClientAsync(nodeType); diff --git a/Tests/Xrpl.Tests/Integration/transactions/checkCash.cs b/Tests/Xrpl.Tests/Integration/transactions/checkCash.cs index 2d0dc80a..f7e101a4 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/checkCash.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/checkCash.cs @@ -20,7 +20,10 @@ public class TestICheckCash private static TestNodeType nodeType = IntegrationTestConfig.CurrentNodeType; [TestMethod] - [Timeout(60000)] + // Sized for a public network: funding comes from a faucet whose calls are serialised + // across the run, and the transaction has to wait for a ledger to close on its own + // rather than one the test forces. On the standalone stand this is never approached. + [Timeout(300000)] public async Task TestRequestMethod() { IXrplClient client = await IntegrationTestConfig.CreateClientAsync(nodeType); From bae1877ba254899fe0b0314bc9b47db2e7caaa2c Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 14:12:48 -0300 Subject: [PATCH 18/26] docs(changes): record the oracle and helper findings --- CHANGES.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 084774ca..58bce3bf 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,10 @@ * 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 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: @@ -26,6 +30,8 @@ * 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 + * 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 From 45ea48dc33f878e1035feca5b8d2a993918a3a1f Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 14:32:34 -0300 Subject: [PATCH 19/26] test: make the suite portable to a clustered public endpoint 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 on devnet; they wait for the account there now. Faucet calls are no longer fully serialised either. 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. Serialising them spent minutes that individual tests were charged for inside their own timeouts, and six path tests timed out on the queue rather than on anything they did. The same batch now finishes in half the time. --- Tests/Xrpl.Tests/Integration/Utils.cs | 40 ++++++++++++++++++- .../Integration/requests/pathFind.cs | 12 ++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/Tests/Xrpl.Tests/Integration/Utils.cs b/Tests/Xrpl.Tests/Integration/Utils.cs index 2a58a0ae..79766b8b 100644 --- a/Tests/Xrpl.Tests/Integration/Utils.cs +++ b/Tests/Xrpl.Tests/Integration/Utils.cs @@ -26,7 +26,18 @@ namespace XrplTests.Xrpl.ClientLib.Integration internal static class StandaloneLock { internal static readonly SemaphoreSlim MasterFunding = new(1, 1); - internal static readonly SemaphoreSlim FaucetFunding = new(1, 1); + + /// + /// Limits how many faucet calls are in flight at once. + /// + /// + /// It was one, from a time when every call went through a shared filler wallet whose + /// sequence could not take concurrency. Each call now funds its own destination and + /// shares nothing, so the only reason left to hold a limit is the faucet's own rate + /// limit. Fully serialised, funding a run's worth of accounts took minutes that + /// individual tests spent inside their own timeouts. + /// + internal static readonly SemaphoreSlim FaucetFunding = new(3, 3); } /// @@ -217,6 +228,33 @@ public static async Task TryFundWalletAsync(IXrplClient client, XrplWallet walle } } + /// + /// Waits until is visible on . + /// + /// + /// Funding confirms the account on the connection that funded it, and on a standalone + /// stand that is the whole network. A public endpoint is a cluster behind one name, so a + /// second connection can land on a server that has not seen the account yet and answer + /// srcActNotFound for it - which is what the path-finding tests, the only ones + /// that open a second client, hit intermittently on devnet. + /// + public static async Task WaitForAccountAsync(IXrplClient client, string address) + { + const int MaxAttempts = 15; + for (int attempt = 1; ; attempt++) + { + try + { + await client.AccountInfo(new AccountInfoRequest(address)).Typed(); + return; + } + catch (RippleException) when (attempt < MaxAttempts) + { + await Task.Delay(TimeSpan.FromSeconds(2)); + } + } + } + /// /// Tops a wallet up until it holds at least . /// A public faucet hands out a fixed amount per call (100 XRP on devnet), which is less diff --git a/Tests/Xrpl.Tests/Integration/requests/pathFind.cs b/Tests/Xrpl.Tests/Integration/requests/pathFind.cs index fce8cd72..021ec00c 100644 --- a/Tests/Xrpl.Tests/Integration/requests/pathFind.cs +++ b/Tests/Xrpl.Tests/Integration/requests/pathFind.cs @@ -44,6 +44,10 @@ public async Task TestPathFindCreate() IXrplClient pfClient = await IntegrationTestConfig.CreateClientAsync(nodeType); try { + // A public endpoint is a cluster: the account was funded over the client above, + // and this connection may be on a server that has not seen it yet + await IntegrationTestConfig.WaitForAccountAsync(pfClient, wallet.ClassicAddress); + Currency destinationAmount = new Currency { CurrencyCode = "USD", @@ -78,6 +82,10 @@ public async Task TestPathFindClose() IXrplClient pfClient = await IntegrationTestConfig.CreateClientAsync(nodeType); try { + // A public endpoint is a cluster: the account was funded over the client above, + // and this connection may be on a server that has not seen it yet + await IntegrationTestConfig.WaitForAccountAsync(pfClient, wallet.ClassicAddress); + Currency destinationAmount = new Currency { CurrencyCode = "USD", @@ -113,6 +121,10 @@ public async Task TestPathFindStatus() IXrplClient pfClient = await IntegrationTestConfig.CreateClientAsync(nodeType); try { + // A public endpoint is a cluster: the account was funded over the client above, + // and this connection may be on a server that has not seen it yet + await IntegrationTestConfig.WaitForAccountAsync(pfClient, wallet.ClassicAddress); + Currency destinationAmount = new Currency { CurrencyCode = "USD", From 354654437a0c7b3acbd2e906e3837f33865158f4 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 14:32:34 -0300 Subject: [PATCH 20/26] docs(changes): record the cluster and faucet findings --- CHANGES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 58bce3bf..71c63ee3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -31,6 +31,8 @@ * 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 + * 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 From d4e5147e7b2020eb7d935d7c3c52e27adf8562de Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 15:24:34 -0300 Subject: [PATCH 21/26] fix(wallet): keep the two-argument ComposeSignatures as an overload Counterparty routing needed a third argument, and it was added with a default. That is 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 in the package, so it fails at run time rather than at build. Both forms are now declared and both are pinned. --- CHANGES.md | 2 ++ .../Wallet/TestULoanCounterpartyMultisign.cs | 23 ++++++++++++++++ Xrpl/Wallet/SignatureComposer.cs | 26 +++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 71c63ee3..990eefab 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -12,6 +12,8 @@ * 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 +* **`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: diff --git a/Tests/Xrpl.Tests/Wallet/TestULoanCounterpartyMultisign.cs b/Tests/Xrpl.Tests/Wallet/TestULoanCounterpartyMultisign.cs index 63b32e61..3650c027 100644 --- a/Tests/Xrpl.Tests/Wallet/TestULoanCounterpartyMultisign.cs +++ b/Tests/Xrpl.Tests/Wallet/TestULoanCounterpartyMultisign.cs @@ -121,6 +121,29 @@ public void TestUCompose_SignerOnSponsorAndCounterpartySides_Throws() StringAssert.Contains(ex.Message, "Ambiguous signer role"); } + /// + /// The two-argument form still exists as its own overload. Folding it into the + /// three-argument method with a default would be source-compatible and binary-breaking: + /// an assembly compiled against it would call a method that no longer exists. + /// + [TestMethod] + public void TestUCompose_TwoArgumentOverload_RoutesSponsorSideOnly() + { + Dictionary prepared = Prepared(); + prepared["SigningPubKey"] = ""; + SignatureResult sponsorSigner = Signer1.Sign(new Dictionary(prepared), multisign: true); + SignatureResult otherSigner = Stranger.Sign(new Dictionary(prepared), multisign: true); + + SignatureResult composed = SignatureComposer.ComposeSignatures( + new[] { sponsorSigner.TxBlob, otherSigner.TxBlob }, + new[] { Signer1.ClassicAddress }); + + JsonObject decoded = XrplBinaryCodec.Decode(composed.TxBlob).AsObject(); + Assert.AreEqual(1, decoded["SponsorSignature"]["Signers"].AsArray().Count); + Assert.AreEqual(1, decoded["Signers"].AsArray().Count); + Assert.IsNull(decoded["CounterpartySignature"], "the two-argument form routes nothing to the counterparty side"); + } + [TestMethod] public void TestUCombine_SingleAndMultisigCounterparty_Throws() { diff --git a/Xrpl/Wallet/SignatureComposer.cs b/Xrpl/Wallet/SignatureComposer.cs index 6e641fb4..8ae30155 100644 --- a/Xrpl/Wallet/SignatureComposer.cs +++ b/Xrpl/Wallet/SignatureComposer.cs @@ -68,6 +68,32 @@ public static SignatureResult ComposeSignatures( IEnumerable partBlobs, IReadOnlyCollection? sponsorSignerAccounts = null, IReadOnlyCollection? counterpartySignerAccounts = null) + { + return Compose(partBlobs, sponsorSignerAccounts, counterpartySignerAccounts); + } + + /// + /// The two-argument form, for sponsor-side routing only. + /// + /// + /// Kept as its own overload rather than folded into the three-argument method above. + /// Adding a parameter with a default is 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, and fails at run time rather than at build. + /// + /// Partially signed blobs of the same transaction. + /// Accounts whose Signer entries belong to the sponsor's SignerList. + public static SignatureResult ComposeSignatures( + IEnumerable partBlobs, + IReadOnlyCollection? sponsorSignerAccounts) + { + return Compose(partBlobs, sponsorSignerAccounts, null); + } + + private static SignatureResult Compose( + IEnumerable partBlobs, + IReadOnlyCollection? sponsorSignerAccounts, + IReadOnlyCollection? counterpartySignerAccounts) { List parts = partBlobs?.Select(b => XrplBinaryCodec.Decode(b).AsObject()).ToList() ?? throw new ValidationException("At least one partially signed blob is required."); From 3eb4f959b0052cfc47dc2232171b9005dce73ec8 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 17:33:11 -0300 Subject: [PATCH 22/26] fix(json): a currency code whose padding is not padding is not 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. Raised in review of #165. --- Xrpl/Client/Json/Converters/OracleConverters.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Xrpl/Client/Json/Converters/OracleConverters.cs b/Xrpl/Client/Json/Converters/OracleConverters.cs index d467f4c3..3accfbdc 100644 --- a/Xrpl/Client/Json/Converters/OracleConverters.cs +++ b/Xrpl/Client/Json/Converters/OracleConverters.cs @@ -208,7 +208,16 @@ private static string DecodeOracleCurrency(string hex) for (int i = 0; i < 20 && bytes[i] != 0; i++) length++; - return OracleAsciiValidation.IsPrintableAscii(bytes.AsSpan(0, length)) + // Everything after the text has to be padding for the text to be the whole value. + // A code like 5553440001... would otherwise read as "USD" and lose the 0x01 when it + // was written back, and a code of twenty zero bytes would read as an empty string. + for (int i = length; i < 20; i++) + { + if (bytes[i] != 0) + return hex; + } + + return length > 0 && OracleAsciiValidation.IsPrintableAscii(bytes.AsSpan(0, length)) ? Encoding.ASCII.GetString(bytes, 0, length) : hex; } From eff91871665a5d81101172707c1904d30f179042 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 17:33:11 -0300 Subject: [PATCH 23/26] docs(xchain): say what attestation verification does not check VerifyClaimAttestation and VerifyAccountCreateAttestation reconstruct the message and check the signature. They say nothing about whether the key may speak for AttestationSignerAccount or whether that account is on the door's SignerList; rippled decides both at preclaim and needs a ledger to do it. Raised in review of #165. --- Xrpl/Wallet/XChainAttestationSigner.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Xrpl/Wallet/XChainAttestationSigner.cs b/Xrpl/Wallet/XChainAttestationSigner.cs index c9f7940c..f1385491 100644 --- a/Xrpl/Wallet/XChainAttestationSigner.cs +++ b/Xrpl/Wallet/XChainAttestationSigner.cs @@ -135,6 +135,14 @@ public static XChainAddAccountCreateAttestation SignAccountCreateAttestation(XCh /// Checks the attestation's signature against its own fields and public key, /// the way rippled's attestationPreflight does (temXCHAIN_BAD_PROOF otherwise). /// + /// + /// Cryptographic only. It says the signature matches the fields and the key, and nothing + /// about whether that key may speak for AttestationSignerAccount or whether that + /// account is on the door's SignerList. rippled decides both at preclaim + /// (checkAttestationPublicKey: tecNO_PERMISSION, or + /// tecXCHAIN_BAD_PUBLIC_KEY_ACCOUNT_PAIR when the key is neither the master nor the + /// regular key of that account), and it needs a ledger to do it. + /// public static bool VerifyClaimAttestation(XChainAddClaimAttestation attestation) { if (attestation is null) throw new ArgumentNullException(nameof(attestation)); @@ -153,7 +161,8 @@ public static bool VerifyClaimAttestation(XChainAddClaimAttestation attestation) } /// - /// Checks the account-create attestation's signature; see . + /// Checks the account-create attestation's signature; see + /// , including what it does not check. /// public static bool VerifyAccountCreateAttestation(XChainAddAccountCreateAttestation attestation) { From 98fb020699fbde8b39942d92476215b973083538 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 17:33:11 -0300 Subject: [PATCH 24/26] test: review findings on running the suite against a public network A seed no longer travels to 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 first. Node-side signing puts the wallet's seed on the wire, which was fine while the only reachable node was on this machine. It is standalone-only now; the rule it checks is rippled's and does not vary by network. TestChangeServer_SwitchesSuccessfully skips rather than asserting a switch that did not happen: the alternate endpoint is the same URL with localhost spelled as 127.0.0.1, which is no alternate at all for an address that does not say localhost. The faucet host is named from the profile instead of inferred from the connection URL, which throws when it recognises nothing: XRPL_TEST_NODE_URL may point at a node whose hostname says nothing about which network it is on. The sponsored escrow test waits for a close time strictly past its mark, the same one-tick error already fixed in the batch matrix. Every second-client path in the path-finding tests waits for its account, not just the three that had it. And path-finding requests retry while the node answers srcActNotFound: rippled answers them from a ledger snapshot of its own that can lag the validated ledger funding confirmed the account on. Raised in review of #165, except the last, which that fix surfaced. --- .../Integration/TestIConnectionStates.cs | 25 +++++++++-- Tests/Xrpl.Tests/Integration/Utils.cs | 45 +++++++++++++++++-- .../Integration/requests/TestIPathPayment.cs | 12 +++-- .../Integration/requests/pathFind.cs | 33 +++++++++++--- .../Integration/requests/ripplePathFind.cs | 6 ++- .../transactions/TestIMemoLimits.cs | 14 ++++++ .../transactions/TestISponsoredTypes.cs | 11 ++++- 7 files changed, 127 insertions(+), 19 deletions(-) diff --git a/Tests/Xrpl.Tests/Integration/TestIConnectionStates.cs b/Tests/Xrpl.Tests/Integration/TestIConnectionStates.cs index 343af6e4..6353942e 100644 --- a/Tests/Xrpl.Tests/Integration/TestIConnectionStates.cs +++ b/Tests/Xrpl.Tests/Integration/TestIConnectionStates.cs @@ -36,8 +36,20 @@ public class TestIConnectionStates /// The same node under a different URL spelling — enough to exercise a real server switch /// (teardown plus reconnect to a new endpoint) without a second container. /// - private static string LocalServerAlternateSpelling => - LocalServer.Replace("localhost", "127.0.0.1", StringComparison.OrdinalIgnoreCase); + /// + /// Null when the spelling cannot be varied, which is any address that does not say + /// localhost. Switching to the string already in use is not a server switch, and the + /// assertion that the client passed through Connecting would hold without one, so the test + /// that needs it skips instead of passing on nothing. + /// + private static string LocalServerAlternateSpelling + { + get + { + string alternate = LocalServer.Replace("localhost", "127.0.0.1", StringComparison.OrdinalIgnoreCase); + return string.Equals(alternate, LocalServer, StringComparison.Ordinal) ? null : alternate; + } + } /// /// A closed port on the loopback interface: refuses immediately and, unlike a bogus @@ -193,6 +205,13 @@ public async Task TestIdempotentConnect_StaysConnected() [TestMethod] public async Task TestChangeServer_SwitchesSuccessfully() { + string alternate = LocalServerAlternateSpelling; + if (alternate is null) + { + Assert.Inconclusive( + $"There is no second spelling of {LocalServer} to switch to, so this would assert a switch that did not happen."); + } + List stateChanges = new List(); XrplClient client = new XrplClient(LocalServer, LocalOptions()); @@ -208,7 +227,7 @@ public async Task TestChangeServer_SwitchesSuccessfully() stateChanges.Clear(); - await client.ChangeServer(LocalServerAlternateSpelling, LocalOptions()); + await client.ChangeServer(alternate, LocalOptions()); await WaitForStateAsync(client, XrpConnectionState.Connected, "Should be Connected after ChangeServer"); Assert.IsTrue( diff --git a/Tests/Xrpl.Tests/Integration/Utils.cs b/Tests/Xrpl.Tests/Integration/Utils.cs index 79766b8b..0a338344 100644 --- a/Tests/Xrpl.Tests/Integration/Utils.cs +++ b/Tests/Xrpl.Tests/Integration/Utils.cs @@ -194,7 +194,7 @@ public static async Task FundWalletAsync(IXrplClient client, XrplWallet wallet, } else if (type == TestNodeType.TestNet || type == TestNodeType.DevNet) { - await FundFromFaucetAsync(client, wallet); + await FundFromFaucetAsync(client, wallet, type); } else { @@ -255,6 +255,31 @@ public static async Task WaitForAccountAsync(IXrplClient client, string address) } } + /// + /// Runs a path-finding request, retrying while the node answers srcActNotFound. + /// + /// + /// Funding confirms the account on the validated ledger, but rippled answers path finding + /// from a ledger snapshot of its own that can lag behind it, so a freshly created source + /// can be absent from path finding for a few ledgers after it plainly exists. Seen on + /// devnet; the stand closes ledgers on demand and never shows it. + /// + public static async Task RetryWhileSourceMissingAsync(Func> request) + { + const int MaxAttempts = 10; + for (int attempt = 1; ; attempt++) + { + try + { + return await request(); + } + catch (RippledException error) when (attempt < MaxAttempts && error.Message.Contains("srcActNotFound")) + { + await Task.Delay(TimeSpan.FromSeconds(2)); + } + } + } + /// /// Tops a wallet up until it holds at least . /// A public faucet hands out a fixed amount per call (100 XRP on devnet), which is less @@ -313,12 +338,22 @@ public static async Task TryFundWalletsAsync(IXrplClient client, TestNodeType? n /// private const int FaucetAttempts = 3; + /// + /// The faucet that funds accounts on . + /// + private static string FaucetHostFor(TestNodeType type) => type switch + { + TestNodeType.DevNet => WalletSugar.FaucetNetwork.Devnet, + TestNodeType.TestNet => WalletSugar.FaucetNetwork.Testnet, + _ => throw new InvalidOperationException($"There is no faucet for {type}."), + }; + /// /// Funds a wallet straight from the testnet/devnet faucet. /// Calls are serialized via StandaloneLock so parallel test classes do not /// hammer the faucet rate limit; each failed call is retried with a growing delay. /// - private static async Task FundFromFaucetAsync(IXrplClient client, XrplWallet wallet) + private static async Task FundFromFaucetAsync(IXrplClient client, XrplWallet wallet, TestNodeType type) { await StandaloneLock.FaucetFunding.WaitAsync(); try @@ -327,7 +362,11 @@ private static async Task FundFromFaucetAsync(IXrplClient client, XrplWallet wal { try { - await client.FundWallet(wallet); + // The host is named rather than left to WalletSugar.GetFaucetHost, which + // infers it from the connection URL and throws when it recognises nothing: + // XRPL_TEST_NODE_URL may point at a node whose hostname says nothing about + // which network it is on. + await client.FundWallet(wallet, FaucetHostFor(type)); decimal balance = await client.GetXrpFreeBalance(wallet.ClassicAddress); Console.WriteLine($"[IntegrationTest] Faucet funded {wallet.ClassicAddress}: {balance} XRP"); return; diff --git a/Tests/Xrpl.Tests/Integration/requests/TestIPathPayment.cs b/Tests/Xrpl.Tests/Integration/requests/TestIPathPayment.cs index 649b9720..36924f88 100644 --- a/Tests/Xrpl.Tests/Integration/requests/TestIPathPayment.cs +++ b/Tests/Xrpl.Tests/Integration/requests/TestIPathPayment.cs @@ -98,7 +98,8 @@ await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, destinationAmount: destinationAmount ); - RipplePathFindResponse pathResponse = await client.RipplePathFind(pathRequest).Typed(); + RipplePathFindResponse pathResponse = await IntegrationTestConfig.RetryWhileSourceMissingAsync( + () => client.RipplePathFind(pathRequest).Typed()); Assert.IsNotNull(pathResponse, "ripple_path_find response should not be null"); Assert.IsNotNull(pathResponse.Alternatives, "Alternatives should not be null"); @@ -287,7 +288,8 @@ await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, } }; - RipplePathFindResponse pathResponse = await client.RipplePathFind(pathRequest).Typed(); + RipplePathFindResponse pathResponse = await IntegrationTestConfig.RetryWhileSourceMissingAsync( + () => client.RipplePathFind(pathRequest).Typed()); Assert.IsNotNull(pathResponse, "ripple_path_find response should not be null"); Assert.IsNotNull(pathResponse.Alternatives, "Alternatives should not be null"); @@ -476,7 +478,8 @@ await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, SendMax = sendMax }; - RipplePathFindResponse pathResponse = await client.RipplePathFind(pathRequest).Typed(); + RipplePathFindResponse pathResponse = await IntegrationTestConfig.RetryWhileSourceMissingAsync( + () => client.RipplePathFind(pathRequest).Typed()); Assert.IsNotNull(pathResponse, "ripple_path_find response should not be null"); Assert.IsNotNull(pathResponse.Alternatives, "Alternatives should not be null"); @@ -598,7 +601,8 @@ await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, SendMax = sendMax }; - RipplePathFindResponse pathResponse = await client.RipplePathFind(pathRequest).Typed(); + RipplePathFindResponse pathResponse = await IntegrationTestConfig.RetryWhileSourceMissingAsync( + () => client.RipplePathFind(pathRequest).Typed()); Assert.IsNotNull(pathResponse, "ripple_path_find response should not be null"); Assert.IsNotNull(pathResponse.Alternatives, "Alternatives should not be null"); diff --git a/Tests/Xrpl.Tests/Integration/requests/pathFind.cs b/Tests/Xrpl.Tests/Integration/requests/pathFind.cs index 021ec00c..f991d2fb 100644 --- a/Tests/Xrpl.Tests/Integration/requests/pathFind.cs +++ b/Tests/Xrpl.Tests/Integration/requests/pathFind.cs @@ -48,6 +48,10 @@ public async Task TestPathFindCreate() // and this connection may be on a server that has not seen it yet await IntegrationTestConfig.WaitForAccountAsync(pfClient, wallet.ClassicAddress); + // A public endpoint is a cluster: the account was funded over the client above, + // and this connection may be on a server that has not seen it yet + await IntegrationTestConfig.WaitForAccountAsync(pfClient, wallet.ClassicAddress); + Currency destinationAmount = new Currency { CurrencyCode = "USD", @@ -61,7 +65,8 @@ public async Task TestPathFindCreate() destinationAmount: destinationAmount ); - PathFindResponse response = await pfClient.PathFind(request).Typed(); + PathFindResponse response = await IntegrationTestConfig.RetryWhileSourceMissingAsync( + () => pfClient.PathFind(request).Typed()); Assert.IsNotNull(response); Assert.IsNotNull(response.Alternatives); Assert.AreEqual(wallet.ClassicAddress, response.DestinationAccount); @@ -86,6 +91,10 @@ public async Task TestPathFindClose() // and this connection may be on a server that has not seen it yet await IntegrationTestConfig.WaitForAccountAsync(pfClient, wallet.ClassicAddress); + // A public endpoint is a cluster: the account was funded over the client above, + // and this connection may be on a server that has not seen it yet + await IntegrationTestConfig.WaitForAccountAsync(pfClient, wallet.ClassicAddress); + Currency destinationAmount = new Currency { CurrencyCode = "USD", @@ -99,7 +108,8 @@ public async Task TestPathFindClose() destinationAmount: destinationAmount ); - await pfClient.PathFind(createRequest); + await IntegrationTestConfig.RetryWhileSourceMissingAsync( + () => pfClient.PathFind(createRequest)); PathFindCloseRequest closeRequest = new PathFindCloseRequest(); PathFindResponse closeResponse = await pfClient.PathFindClose(closeRequest).Typed(); @@ -125,6 +135,10 @@ public async Task TestPathFindStatus() // and this connection may be on a server that has not seen it yet await IntegrationTestConfig.WaitForAccountAsync(pfClient, wallet.ClassicAddress); + // A public endpoint is a cluster: the account was funded over the client above, + // and this connection may be on a server that has not seen it yet + await IntegrationTestConfig.WaitForAccountAsync(pfClient, wallet.ClassicAddress); + Currency destinationAmount = new Currency { CurrencyCode = "USD", @@ -138,7 +152,8 @@ public async Task TestPathFindStatus() destinationAmount: destinationAmount ); - await pfClient.PathFind(createRequest); + await IntegrationTestConfig.RetryWhileSourceMissingAsync( + () => pfClient.PathFind(createRequest)); PathFindStatusRequest statusRequest = new PathFindStatusRequest(); PathFindResponse statusResponse = await pfClient.PathFindStatus(statusRequest).Typed(); @@ -160,6 +175,9 @@ public async Task TestPathFindStreamReceivesMultipleUpdates() await IntegrationTestConfig.TryFundWalletAsync(client, wallet, nodeType); IXrplClient streamClient = await IntegrationTestConfig.CreateClientAsync(nodeType); + // A public endpoint is a cluster: the account was funded over the client above, + // and this connection may be on a server that has not seen it yet + await IntegrationTestConfig.WaitForAccountAsync(streamClient, wallet.ClassicAddress); List received = new List(); TaskCompletionSource tcs = new TaskCompletionSource(); @@ -190,7 +208,8 @@ public async Task TestPathFindStreamReceivesMultipleUpdates() destinationAmount: destinationAmount ); - PathFindResponse response = await streamClient.PathFind(request).Typed(); + PathFindResponse response = await IntegrationTestConfig.RetryWhileSourceMissingAsync( + () => streamClient.PathFind(request).Typed()); Assert.IsNotNull(response, "Initial path_find create response should not be null"); Console.WriteLine($"[PathFind RPC] destination={response.DestinationAccount}, alternatives={response.Alternatives?.Count}"); @@ -329,7 +348,8 @@ await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, SendMax = sendMax }; - PathFindResponse response = await pfClient.PathFind(request).Typed(); + PathFindResponse response = await IntegrationTestConfig.RetryWhileSourceMissingAsync( + () => pfClient.PathFind(request).Typed()); Assert.IsNotNull(response, "path_find create response should not be null"); Assert.IsNotNull(response.Alternatives, "Alternatives should not be null"); @@ -452,7 +472,8 @@ await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, SendMax = sendMax }; - PathFindResponse response = await pfClient.PathFind(request).Typed(); + PathFindResponse response = await IntegrationTestConfig.RetryWhileSourceMissingAsync( + () => pfClient.PathFind(request).Typed()); Assert.IsNotNull(response, "path_find create response should not be null"); Assert.IsNotNull(response.Alternatives, "Alternatives should not be null"); diff --git a/Tests/Xrpl.Tests/Integration/requests/ripplePathFind.cs b/Tests/Xrpl.Tests/Integration/requests/ripplePathFind.cs index e8e04267..000aad7c 100644 --- a/Tests/Xrpl.Tests/Integration/requests/ripplePathFind.cs +++ b/Tests/Xrpl.Tests/Integration/requests/ripplePathFind.cs @@ -50,7 +50,8 @@ public async Task TestRequestMethod() destinationAmount: destinationAmount ); - RipplePathFindResponse response = await client.RipplePathFind(request).Typed(); + RipplePathFindResponse response = await IntegrationTestConfig.RetryWhileSourceMissingAsync( + () => client.RipplePathFind(request).Typed()); Assert.IsNotNull(response); Assert.IsNotNull(response.Alternatives); Assert.IsNotNull(response.DestinationCurrencies); @@ -82,7 +83,8 @@ public async Task TestRequestWithSourceCurrencies() } }; - RipplePathFindResponse response = await client.RipplePathFind(request).Typed(); + RipplePathFindResponse response = await IntegrationTestConfig.RetryWhileSourceMissingAsync( + () => client.RipplePathFind(request).Typed()); Assert.IsNotNull(response); Assert.IsNotNull(response.Alternatives); } diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIMemoLimits.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIMemoLimits.cs index bb0cf834..9fb22e3e 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIMemoLimits.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIMemoLimits.cs @@ -94,9 +94,23 @@ public async Task TestIMemoAtTheLimitIsAccepted() /// One byte over, and the node refuses it - which is what the local check exists to save the /// caller from discovering after signing. /// + /// + /// Standalone only, and the reason is the secret below. Hearing the node refuse this + /// means letting the node sign it, because the SDK's own rules stop it before a signature + /// exists - and node-side signing puts the seed on the wire. That is acceptable to a node + /// running on this machine and not to one someone else operates, which is what the profile + /// can now point at. The rule under test is rippled's and does not vary by network, so the + /// stand answers the question just as well. + /// [TestMethod] public async Task TestIMemoOverTheLimitIsRefusedByTheNode() { + if (!IntegrationTestConfig.IsStandalone()) + { + Assert.Inconclusive( + "This test signs on the node, which sends the wallet's seed to it. Only run against the local stand."); + } + Dictionary tx = PaymentWithMemo(LargestMemoDataInOneMemo + 1); tx["Fee"] = "12"; AccountInfo account = (await client.AccountInfo(new AccountInfoRequest(wallet.ClassicAddress))).Result; diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredTypes.cs b/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredTypes.cs index 1155a462..eba7a3a3 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredTypes.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredTypes.cs @@ -125,9 +125,18 @@ private static async Task ValidatedCloseTimeAsync() return entity.CloseTime ?? throw new InvalidOperationException("validated ledger has no close_time"); } + /// + /// Waits until the validated close time is strictly past . + /// + /// + /// Strictly: rippled's time gates are now > mark (after() in View.cpp), so a + /// close time equal to the mark is still too early and the escrow case below would submit its + /// EscrowCancel a tick short. Standalone close times move in coarse steps and land on + /// equality readily. + /// private static async Task WaitForCloseTimeAsync(DateTime target) { - while (await ValidatedCloseTimeAsync() < target) + while (await ValidatedCloseTimeAsync() <= target) { await Task.Delay(TimeSpan.FromSeconds(3)); } From 75cfa6f7046c34c371b5c6d5fed2ff83f7f4d456 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 17:33:11 -0300 Subject: [PATCH 25/26] docs(changes): record the review findings --- CHANGES.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 990eefab..25d60210 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -12,6 +12,10 @@ * 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. @@ -33,6 +37,7 @@ * 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 + * 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 From a3e1cf02a7fe092681feecbb885c5a89422d7786 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Thu, 3 Sep 2026 20:35:08 -0300 Subject: [PATCH 26/26] test: bound the wait for a close time Both copies of WaitForCloseTimeAsync polled without a deadline. A ledger that stops advancing is a node failure, and a test that waits on it forever 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. TestIEscrow already bounded its own copy this way. Raised in review of #165. --- CHANGES.md | 1 + .../transactions/TestIBatchInnerTypes.cs | 25 ++++++++++++++--- .../transactions/TestISponsoredTypes.cs | 27 ++++++++++++++++--- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 25d60210..836d17c9 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -37,6 +37,7 @@ * 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 diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIBatchInnerTypes.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIBatchInnerTypes.cs index a268f243..b9c67194 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIBatchInnerTypes.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIBatchInnerTypes.cs @@ -107,13 +107,32 @@ private static async Task ValidatedCloseTimeAsync() /// /// Strictly: rippled's time gates are now > mark (after() in View.cpp), so a /// close time equal to the mark is still too early. Standalone close times move in coarse - /// steps and land on equality readily, which is where this was found; on devnet the next - /// step came within seconds and hid it. + /// steps and land on equality readily. + /// + /// Bounded, because a ledger that stops advancing is a node failure and a test that waits on + /// it forever reports nothing. The failure 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. + /// /// private static async Task WaitForCloseTimeAsync(DateTime target) { - while (await ValidatedCloseTimeAsync() <= target) + TimeSpan budget = TimeSpan.FromSeconds(90); + System.Diagnostics.Stopwatch elapsed = System.Diagnostics.Stopwatch.StartNew(); + DateTime lastSeen = DateTime.MinValue; + + while (true) { + lastSeen = await ValidatedCloseTimeAsync(); + if (lastSeen > target) + return; + + if (elapsed.Elapsed >= budget) + { + Assert.Fail( + $"the validated close time did not pass {target:O} within {budget.TotalSeconds:F0}s; " + + $"last seen {lastSeen:O}, short by {(target - lastSeen).TotalSeconds:F1}s"); + } + await Task.Delay(TimeSpan.FromSeconds(3)); } } diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredTypes.cs b/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredTypes.cs index eba7a3a3..759c6cfd 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredTypes.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredTypes.cs @@ -130,14 +130,33 @@ private static async Task ValidatedCloseTimeAsync() /// /// /// Strictly: rippled's time gates are now > mark (after() in View.cpp), so a - /// close time equal to the mark is still too early and the escrow case below would submit its - /// EscrowCancel a tick short. Standalone close times move in coarse steps and land on - /// equality readily. + /// close time equal to the mark is still too early. Standalone close times move in coarse + /// steps and land on equality readily. + /// + /// Bounded, because a ledger that stops advancing is a node failure and a test that waits on + /// it forever reports nothing. The failure 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. + /// /// private static async Task WaitForCloseTimeAsync(DateTime target) { - while (await ValidatedCloseTimeAsync() <= target) + TimeSpan budget = TimeSpan.FromSeconds(90); + System.Diagnostics.Stopwatch elapsed = System.Diagnostics.Stopwatch.StartNew(); + DateTime lastSeen = DateTime.MinValue; + + while (true) { + lastSeen = await ValidatedCloseTimeAsync(); + if (lastSeen > target) + return; + + if (elapsed.Elapsed >= budget) + { + Assert.Fail( + $"the validated close time did not pass {target:O} within {budget.TotalSeconds:F0}s; " + + $"last seen {lastSeen:O}, short by {(target - lastSeen).TotalSeconds:F1}s"); + } + await Task.Delay(TimeSpan.FromSeconds(3)); } }