From 19b28cf74417b4af81a073b4d3d17448f13b0e2c Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Fri, 4 Sep 2026 09:56:11 -0300 Subject: [PATCH 1/4] ci(devnet): put the two newest coverage classes in the default filter TestIXChainAttestation and TestILoanMultisig were written after this workflow and never added to it. Between them they are the only traffic for both attestation transaction types, XChainClaim, and LoanSet.CounterpartySignature.Signers, so a default run covered none of that. Verified by dispatching with the corrected filter: 101 tests, 92 passed, 9 skipped for MPTokensV2 being absent from devnet, none failed. --- .github/workflows/devnet-coverage.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/devnet-coverage.yml b/.github/workflows/devnet-coverage.yml index 45c766c3..e8ec9661 100644 --- a/.github/workflows/devnet-coverage.yml +++ b/.github/workflows/devnet-coverage.yml @@ -7,10 +7,15 @@ name: Devnet Coverage # 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 +# 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. # +# Keep the default filter in step with the classes that carry coverage-oriented traffic. +# TestIXChainAttestation and TestILoanMultisig were written after this workflow and were +# missing from it for a while: between them they are the only traffic for both attestation +# transaction types, XChainClaim, and LoanSet.CounterpartySignature.Signers. +# # 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 @@ -33,7 +38,7 @@ on: description: dotnet test --filter expression type: string default: >- - FullyQualifiedName~TestIBatchInnerTypes|FullyQualifiedName~TestISponsoredTypes|FullyQualifiedName~TestIAMMMpt|FullyQualifiedName~TestIXChainBridge|FullyQualifiedName~TestIBatch|FullyQualifiedName~TestISponsorship|FullyQualifiedName~TestILedgerStateFix + FullyQualifiedName~TestIBatchInnerTypes|FullyQualifiedName~TestISponsoredTypes|FullyQualifiedName~TestIAMMMpt|FullyQualifiedName~TestIXChainAttestation|FullyQualifiedName~TestILoanMultisig|FullyQualifiedName~TestIXChainBridge|FullyQualifiedName~TestIBatch|FullyQualifiedName~TestISponsorship|FullyQualifiedName~TestILedgerStateFix env: DOTNET_VERSION: '10.0.x' From 69a729031cf61f5624cb117bfc718bbf483aa0bb Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Fri, 4 Sep 2026 14:41:18 -0300 Subject: [PATCH 2/4] test(sponsor): the Sponsor field on Vault, Loan and bridge attestations These are the types rippled forbids inside a Batch (Batch::preflight kDisabledTxTypes), so whether they take a sponsor at all was worth establishing rather than assuming. They do: preflight1Sponsor in Transactor.cpp constrains only spfSponsorReserve, through the allow-list in isReserveSponsorAllowed, and no Vault or Loan type is on it. Fee sponsorship is unconstrained. A sponsored LoanSet carries three signatures at once - the broker's own, the borrower's CounterpartySignature and the sponsor's SponsorSignature - and this is the first time the composer has had to place all three in one transaction. LoanBrokerCoverWithdraw, LoanBrokerCoverClawback and LoanManage had never been submitted to a node by any test. Clawing broker cover back is the asset issuer's move and rippled refuses it on a native asset, so that case needs an IOU-backed vault whose issuer is a third account. Two rules from LoanBrokerSet::preflight that are easy to trip are written down where the test lives: VaultID is required even when the transaction updates a broker that already exists, and a transaction naming a LoanBrokerID may not carry ManagementFeeRate, CoverRateMinimum or CoverRateLiquidation - those are set once, at creation, and an update carrying one is temINVALID. VaultDelete.MemoData is left out: the field is optional on rippled's develop branch and the release build the CI stand runs answers temDISABLED for it, so a test carrying it would report the stand's version rather than the SDK. Verified: 337 of 337 on the standalone stand, 12 of 12 against devnet. The Sponsor amendment reads 109 of 109 on the coverage dashboard afterwards. --- CHANGES.md | 8 + .../transactions/TestISponsoredVaultLoan.cs | 472 ++++++++++++++++++ .../transactions/TestIXChainAttestation.cs | 103 ++++ 3 files changed, 583 insertions(+) create mode 100644 Tests/Xrpl.Tests/Integration/transactions/TestISponsoredVaultLoan.cs diff --git a/CHANGES.md b/CHANGES.md index 836d17c9..122215d5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,13 @@ # Changes +## Unreleased + +* **The `Sponsor` field is exercised on the Vault and Loan transaction types, and on the bridge attestations.** These are the types rippled forbids inside a Batch (`Batch::preflight` kDisabledTxTypes), so whether they take a sponsor at all was worth establishing rather than assuming. They do: `preflight1Sponsor` in `Transactor.cpp` constrains only `spfSponsorReserve`, through the allow-list in `isReserveSponsorAllowed`, and no Vault or Loan type is on it. Fee sponsorship is unconstrained. + * **a sponsored `LoanSet` carries three signatures at once** - the broker's own, the borrower's `CounterpartySignature` and the sponsor's `SponsorSignature` - and this is the first time the composer has had to place all three in one transaction + * `LoanBrokerCoverWithdraw`, `LoanBrokerCoverClawback` and `LoanManage` had never been submitted to a node by any test. Clawing broker cover back is the asset issuer's move and rippled refuses it on a native asset, so that case needs an IOU-backed vault whose issuer is a third account + * two rules from `LoanBrokerSet::preflight` that are easy to trip, now written down where the test lives: `VaultID` is required even when the transaction updates a broker that already exists, and a transaction naming a `LoanBrokerID` may not carry `ManagementFeeRate`, `CoverRateMinimum` or `CoverRateLiquidation` - those are set once, at creation, and an update carrying one is `temINVALID` + * `VaultDelete.MemoData` is left out. The field is optional on rippled's develop branch and the release build the CI stand runs answers `temDISABLED` for it, so a test carrying it would report the stand's version rather than anything about the SDK + ## 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. diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredVaultLoan.cs b/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredVaultLoan.cs new file mode 100644 index 00000000..17426e0c --- /dev/null +++ b/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredVaultLoan.cs @@ -0,0 +1,472 @@ +using System; +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.Exceptions; +using Xrpl.Client.Json; +using Xrpl.Models; +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; + +/// +/// The Sponsor field (XLS-68) on the Vault and Loan transaction types. +/// +/// +/// These are the types rippled forbids inside a Batch (Batch::preflight +/// kDisabledTxTypes), so whether they take a sponsor at all is worth establishing rather +/// than assuming. They do: preflight1Sponsor in Transactor.cpp constrains only +/// spfSponsorReserve, through the allow-list in isReserveSponsorAllowed, and +/// no Vault or Loan type is on it. Fee sponsorship is unconstrained, which is what these +/// tests use. +/// +/// LoanSet is the interesting one: sponsored, it carries three signatures at once - +/// the broker's own, the borrower's CounterpartySignature and the sponsor's +/// SponsorSignature - and the composer has to place all three. +/// +/// +[TestClass] +[TestCategory("Sponsorship")] +public class TestISponsoredVaultLoan : TestILoanBase +{ + private static bool sponsorAmendmentActive; + + private static IXrplClient client; + protected override IXrplClient GetClient() => client; + + private const string CurrencyCode = "USD"; + + [ClassInitialize] + public static async Task ClassInitializeAsync(TestContext testContext) + { + client = await CreateStandaloneClient(); + 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 + + 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}"); + } + + /// + /// Opens a fee sponsorship from for . + /// + /// + /// The Sponsorship entry lands in the sponsee's owner directory as well, so anything that + /// needs an empty directory - asfAllowTrustLineClawback - has to happen first. + /// + 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"); + } + + /// Stamps the sponsor onto the transaction and submits it with both signatures. + private static async Task SubmitSponsoredAsync(T tx, XrplWallet sponsee, XrplWallet sponsor) + where T : TransactionRequest + { + Assert.AreEqual(sponsee.ClassicAddress, tx.Account, "the sponsored transaction must be the sponsee's"); + tx.Sponsor = sponsor.ClassicAddress; + tx.SponsorFlags = SponsorCoverage.spfSponsorFee; + + 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; + } + + /// + /// Creates a broker on an existing vault and deposits cover, returning the LoanBrokerID. + /// + /// + /// makes the vault itself and does not hand the + /// VaultID back, and LoanBrokerSet needs it on every call, update included. + /// + private static async Task CreateBrokerOnVault(XrplWallet broker, string vaultId) + { + await SubmitPlainAsync(new VaultDeposit + { + Account = broker.ClassicAddress, + VaultID = vaultId, + Amount = new Currency { Value = "100000000", CurrencyCode = "XRP" }, + }, broker, "VaultDeposit"); + + TransactionSummary brokerResult = await client.SubmitAndWait( + await client.Autofill(new LoanBrokerSet { Account = broker.ClassicAddress, VaultID = vaultId }), broker, true); + ValidateResult(brokerResult); + string brokerId = GetCreatedObjectId(brokerResult, LedgerEntryType.LoanBroker); + Assert.IsNotNull(brokerId, "the LoanBrokerSet must report the new LoanBroker"); + + await SubmitPlainAsync(new LoanBrokerCoverDeposit + { + Account = broker.ClassicAddress, + LoanBrokerID = brokerId, + Amount = new Currency { Value = "50000000", CurrencyCode = "XRP" }, + }, broker, "LoanBrokerCoverDeposit"); + + return brokerId; + } + + private static Currency Iou(string issuer, string value) => + new Currency { CurrencyCode = CurrencyCode, Issuer = issuer, Value = value }; + + private static string ToHex(string text) => Convert.ToHexString(System.Text.Encoding.UTF8.GetBytes(text)); + + #endregion + + /// + /// An XRP vault owned by the sponsee: created, configured, funded, drained and removed, + /// every step with the sponsor covering the fee. + /// + [TestMethod] + public async Task Sponsored_Vault_Create_Set_Deposit_Withdraw_Delete() + { + XrplWallet sponsor = XrplWallet.Generate(); + XrplWallet owner = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, sponsor, owner); + await IntegrationTestConfig.EnsureBalanceAsync(client, owner, 150m); + await OpenSponsorshipAsync(sponsor, owner); + + TransactionSummary created = await SubmitSponsoredAsync(new VaultCreate + { + Account = owner.ClassicAddress, + Asset = new IssuedCurrency { Currency = "XRP" }, + }, owner, sponsor); + string vaultId = GetCreatedObjectId(created, LedgerEntryType.Vault); + Assert.IsNotNull(vaultId, "the VaultCreate must report the new Vault"); + + await SubmitSponsoredAsync(new VaultSet + { + Account = owner.ClassicAddress, + VaultID = vaultId, + Data = ToHex("sponsored vault"), + }, owner, sponsor); + + await SubmitSponsoredAsync(new VaultDeposit + { + Account = owner.ClassicAddress, + VaultID = vaultId, + Amount = new Currency { ValueAsXrp = 10m }, + }, owner, sponsor); + + await SubmitSponsoredAsync(new VaultWithdraw + { + Account = owner.ClassicAddress, + VaultID = vaultId, + Amount = new Currency { ValueAsXrp = 10m }, + }, owner, sponsor); + + // Without MemoData: it is an optional field of VaultDelete on rippled's develop branch + // and the release build the CI stand runs answers temDISABLED for it, so carrying it + // here would make this test say more about the stand's version than about sponsorship + await SubmitSponsoredAsync(new VaultDelete + { + Account = owner.ClassicAddress, + VaultID = vaultId, + }, owner, sponsor); + } + + /// + /// Clawing back from a vault is the issuer's move, so the sponsee here issues the asset, + /// owns the vault, and a separate holder is the one clawed back from. + /// + [TestMethod] + public async Task Sponsored_VaultClawback() + { + XrplWallet sponsor = XrplWallet.Generate(); + XrplWallet issuer = XrplWallet.Generate(); + XrplWallet holder = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, sponsor, issuer, holder); + + // Both flags before the sponsorship: asfAllowTrustLineClawback needs an empty owner + // directory, and the Sponsorship entry counts against it + 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 OpenSponsorshipAsync(sponsor, issuer); + + TransactionSummary created = await SubmitSponsoredAsync(new VaultCreate + { + Account = issuer.ClassicAddress, + Asset = new IssuedCurrency { Currency = CurrencyCode, Issuer = issuer.ClassicAddress }, + }, issuer, sponsor); + string vaultId = GetCreatedObjectId(created, LedgerEntryType.Vault); + Assert.IsNotNull(vaultId, "the VaultCreate must report the new Vault"); + + await SubmitPlainAsync(new TrustSet + { + Account = holder.ClassicAddress, + LimitAmount = Iou(issuer.ClassicAddress, "1000"), + }, holder, "TrustSet"); + await SubmitPlainAsync(new Payment + { + Account = issuer.ClassicAddress, + Destination = holder.ClassicAddress, + Amount = Iou(issuer.ClassicAddress, "100"), + }, issuer, "issue tokens"); + await SubmitPlainAsync(new VaultDeposit + { + Account = holder.ClassicAddress, + VaultID = vaultId, + Amount = Iou(issuer.ClassicAddress, "100"), + }, holder, "VaultDeposit by holder"); + + await SubmitSponsoredAsync(new VaultClawback + { + Account = issuer.ClassicAddress, + VaultID = vaultId, + Holder = holder.ClassicAddress, + Amount = Iou(issuer.ClassicAddress, "40"), + }, issuer, sponsor); + } + + /// + /// The broker lifecycle with the sponsor covering every fee: reconfigure, add cover, + /// take cover back, and delete. + /// + [TestMethod] + public async Task Sponsored_LoanBroker_Set_CoverDeposit_CoverWithdraw_Delete() + { + XrplWallet sponsor = XrplWallet.Generate(); + XrplWallet broker = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, sponsor, broker); + await IntegrationTestConfig.EnsureBalanceAsync(client, broker, 200m); + + string vaultId = await CreateVaultForBroker(client, broker); + string brokerId = await CreateBrokerOnVault(broker, vaultId); + await OpenSponsorshipAsync(sponsor, broker); + + // Two rules from LoanBrokerSet::preflight, both easy to trip: VaultID is required even + // when the transaction updates a broker that already exists, and a transaction that + // names a LoanBrokerID may not carry ManagementFeeRate, CoverRateMinimum or + // CoverRateLiquidation - those are set once, at creation, and an update carrying one + // is temINVALID + await SubmitSponsoredAsync(new LoanBrokerSet + { + Account = broker.ClassicAddress, + VaultID = vaultId, + LoanBrokerID = brokerId, + Data = ToHex("sponsored broker"), + }, broker, sponsor); + + await SubmitSponsoredAsync(new LoanBrokerCoverDeposit + { + Account = broker.ClassicAddress, + LoanBrokerID = brokerId, + Amount = new Currency { Value = "10000000", CurrencyCode = "XRP" }, + }, broker, sponsor); + + await SubmitSponsoredAsync(new LoanBrokerCoverWithdraw + { + Account = broker.ClassicAddress, + LoanBrokerID = brokerId, + Amount = new Currency { Value = "5000000", CurrencyCode = "XRP" }, + }, broker, sponsor); + + await SubmitSponsoredAsync(new LoanBrokerDelete + { + Account = broker.ClassicAddress, + LoanBrokerID = brokerId, + }, broker, sponsor); + } + + /// + /// Clawing broker cover back is the asset issuer's move and rippled refuses it on a native + /// asset (LoanBrokerCoverClawback::preclaim: "Cannot clawback native asset"), so the + /// broker here sits on an IOU vault and the issuer is a separate, sponsored account. + /// + [TestMethod] + public async Task Sponsored_LoanBrokerCoverClawback() + { + XrplWallet sponsor = XrplWallet.Generate(); + XrplWallet issuer = XrplWallet.Generate(); + XrplWallet broker = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, sponsor, issuer, broker); + + // Both issuer flags before the sponsorship: the Sponsorship entry counts against the + // empty owner directory asfAllowTrustLineClawback requires + 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 OpenSponsorshipAsync(sponsor, issuer); + + await SubmitPlainAsync(new TrustSet + { + Account = broker.ClassicAddress, + LimitAmount = Iou(issuer.ClassicAddress, "1000000"), + }, broker, "TrustSet"); + await SubmitPlainAsync(new Payment + { + Account = issuer.ClassicAddress, + Destination = broker.ClassicAddress, + Amount = Iou(issuer.ClassicAddress, "10000"), + }, issuer, "issue tokens to the broker"); + + TransactionSummary vaultResult = await client.SubmitAndWait( + await client.Autofill(new VaultCreate + { + Account = broker.ClassicAddress, + Asset = new IssuedCurrency { Currency = CurrencyCode, Issuer = issuer.ClassicAddress }, + }), broker, true); + ValidateResult(vaultResult); + string vaultId = GetCreatedObjectId(vaultResult, LedgerEntryType.Vault); + Assert.IsNotNull(vaultId, "the VaultCreate must report the new Vault"); + + await SubmitPlainAsync(new VaultDeposit + { + Account = broker.ClassicAddress, + VaultID = vaultId, + Amount = Iou(issuer.ClassicAddress, "5000"), + }, broker, "VaultDeposit"); + + TransactionSummary brokerResult = await client.SubmitAndWait( + await client.Autofill(new LoanBrokerSet { Account = broker.ClassicAddress, VaultID = vaultId }), broker, true); + ValidateResult(brokerResult); + string brokerId = GetCreatedObjectId(brokerResult, LedgerEntryType.LoanBroker); + Assert.IsNotNull(brokerId, "the LoanBrokerSet must report the new LoanBroker"); + + await SubmitPlainAsync(new LoanBrokerCoverDeposit + { + Account = broker.ClassicAddress, + LoanBrokerID = brokerId, + Amount = Iou(issuer.ClassicAddress, "1000"), + }, broker, "LoanBrokerCoverDeposit"); + + await SubmitSponsoredAsync(new LoanBrokerCoverClawback + { + Account = issuer.ClassicAddress, + LoanBrokerID = brokerId, + Amount = Iou(issuer.ClassicAddress, "400"), + }, issuer, sponsor); + } + + /// + /// A sponsored LoanSet carries three signatures: the broker's own, the borrower's + /// CounterpartySignature and the sponsor's SponsorSignature, all over one preimage. + /// + [TestMethod] + public async Task Sponsored_LoanSet_ThreeWaySignature() + { + XrplWallet sponsor = XrplWallet.Generate(); + XrplWallet broker = XrplWallet.Generate(); + XrplWallet borrower = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, sponsor, broker, borrower); + + string brokerId = await CreateBroker(client, broker); + await OpenSponsorshipAsync(sponsor, broker); + + LoanSet loanTx = new LoanSet + { + Account = broker.ClassicAddress, + LoanBrokerID = brokerId, + Counterparty = borrower.ClassicAddress, + PrincipalRequested = "10000000", + Sponsor = sponsor.ClassicAddress, + SponsorFlags = SponsorCoverage.spfSponsorFee, + }; + Dictionary autofilled = await client.Autofill(loanTx.ToDictionary()); + JsonObject prepared = LoanSigningHelper.PrepareForSigning( + JsonNode.Parse(JsonSerializer.Serialize(autofilled, XrplJsonOptions.Default)).AsObject(), broker); + Dictionary preparedDict = JsonSerializer.Deserialize>( + prepared.ToJsonString(), XrplJsonOptions.Default); + + // Each party signs its own copy of the same preimage; Sign routes by role + string brokerPart = broker.Sign(new Dictionary(preparedDict)).TxBlob; + string borrowerPart = borrower.Sign(new Dictionary(preparedDict)).TxBlob; + string sponsorPart = sponsor.Sign(new Dictionary(preparedDict)).TxBlob; + + SignatureResult composed = SignatureComposer.ComposeSignatures(new[] { brokerPart, borrowerPart, sponsorPart }); + + JsonObject decoded = XrplBinaryCodec.Decode(composed.TxBlob).AsObject(); + Assert.IsNotNull(decoded["TxnSignature"], "the broker's own signature must be present"); + Assert.IsNotNull(decoded["CounterpartySignature"], "the borrower's co-signature must be present"); + Assert.IsNotNull(decoded["SponsorSignature"], "the sponsor's co-signature must be present"); + + TransactionSummary result = await SubmitSignedLoanSet(client, composed.TxBlob); + ValidateResult(result); + } + + /// + /// What a borrower does with a loan, sponsored: pay it, have its state managed, and + /// have it removed once repaid. + /// + [TestMethod] + public async Task Sponsored_Loan_Manage_Pay_Delete() + { + XrplWallet sponsor = XrplWallet.Generate(); + XrplWallet broker = XrplWallet.Generate(); + XrplWallet borrower = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, sponsor, broker, borrower); + + string brokerId = await CreateBroker(client, broker); + + LoanSet loanTx = new LoanSet + { + Account = broker.ClassicAddress, + LoanBrokerID = brokerId, + Counterparty = borrower.ClassicAddress, + PrincipalRequested = "10000000", + }; + TransactionSummary loanResult = await SubmitLoanSetWithCounterpartySig(client, loanTx, broker, borrower); + ValidateResult(loanResult); + string loanId = GetCreatedObjectId(loanResult, LedgerEntryType.Loan); + Assert.IsNotNull(loanId, "the LoanSet must report the new Loan"); + + // The borrower pays, the broker manages and deletes: two sponsees, one sponsor + await OpenSponsorshipAsync(sponsor, borrower); + await OpenSponsorshipAsync(sponsor, broker); + + await SubmitSponsoredAsync(new LoanManage + { + Account = broker.ClassicAddress, + LoanID = loanId, + Flags = LoanManageFlags.tfLoanImpair, + }, broker, sponsor); + + // The full principal: anything less is tecINSUFFICIENT_PAYMENT + await SubmitSponsoredAsync(new LoanPay + { + Account = borrower.ClassicAddress, + LoanID = loanId, + Amount = new Currency { Value = "10000000", CurrencyCode = "XRP" }, + }, borrower, sponsor); + + await SubmitSponsoredAsync(new LoanDelete + { + Account = broker.ClassicAddress, + LoanID = loanId, + }, broker, sponsor); + } +} diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIXChainAttestation.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIXChainAttestation.cs index cd09268f..eff50ab2 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIXChainAttestation.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIXChainAttestation.cs @@ -77,6 +77,34 @@ await SubmitAsync(new SignerListSet }, door); } + /// Opens a fee sponsorship, and skips the test when XLS-68 is not on the node. + private static async Task OpenSponsorshipAsync(XrplWallet sponsor, XrplWallet sponsee) + { + if (!await AmendmentGuard.IsEnabledAsync(client, AmendmentGuard.Sponsor)) + { + Assert.Inconclusive("Sponsor amendment (XLS-68) is not enabled on the test node."); + } + + await SubmitAsync(new SponsorshipSet + { + Account = sponsor.ClassicAddress, + Sponsee = sponsee.ClassicAddress, + FeeAmountDelta = new Currency { ValueAsXrp = 20m }, + RemainingOwnerCountDelta = 10, + }, sponsor); + } + + /// Stamps the sponsor onto the transaction and submits it with both signatures. + private static async Task SubmitSponsoredAsync(T tx, XrplWallet sponsee, XrplWallet sponsor) + where T : TransactionRequest + { + tx.Sponsor = sponsor.ClassicAddress; + tx.SponsorFlags = SponsorCoverage.spfSponsorFee; + 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}"); + } + private static async Task IouBalanceAsync(string holder, string issuer) { AccountLines lines = await client.AccountLines(new AccountLinesRequest(holder)).Typed(); @@ -239,4 +267,79 @@ XChainAddAccountCreateAttestation Attestation(XrplWallet witness) => XChainAttes 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"); } + /// + /// The witness half with the fee sponsored: an attestation is a transaction like any other, + /// and a witness that does not hold XRP of its own is the obvious reason to sponsor one. + /// + [TestMethod] + public async Task Sponsored_XChainAddClaimAttestation_And_XChainClaim() + { + IouBridge setup = await CommitOnIouBridgeAsync(); + XrplWallet sponsor = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, sponsor); + await OpenSponsorshipAsync(sponsor, setup.Witness); + await OpenSponsorshipAsync(sponsor, setup.Recipient); + + // No Destination: the funds wait for an explicit claim, which is what makes the + // sponsored XChainClaim below reachable in the same flow + XChainAddClaimAttestation attestation = + XChainAttestationSigner.SignClaimAttestation(ClaimAttestation(setup, destination: null), setup.Witness); + await SubmitSponsoredAsync(attestation, setup.Witness, sponsor); + Assert.AreEqual(0m, await IouBalanceAsync(setup.Recipient.ClassicAddress, setup.Issuer.ClassicAddress)); + + await SubmitSponsoredAsync(new XChainClaim + { + Account = setup.Recipient.ClassicAddress, + XChainBridge = setup.Bridge, + XChainClaimID = "1", + Destination = setup.Recipient.ClassicAddress, + Amount = Iou(setup.Issuer.ClassicAddress, "100"), + }, setup.Recipient, sponsor); + + 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 Sponsored_XChainAddAccountCreateAttestation() + { + XrplWallet door = XrplWallet.Generate(); + XrplWallet witness = XrplWallet.Generate(); + XrplWallet user = XrplWallet.Generate(); + XrplWallet sponsor = XrplWallet.Generate(); + XrplWallet created = XrplWallet.Generate(); + await IntegrationTestConfig.TryFundWalletsAsync(client, nodeType, door, witness, user, sponsor); + + XChainBridgeModel bridge = CreateXrpTestBridge(door.ClassicAddress); + await SubmitAsync(new XChainCreateBridge { Account = door.ClassicAddress, XChainBridge = bridge, SignatureReward = Drops("100"), MinAccountCreateAmount = Drops("10000000") }, door); + await SetWitnessesAsync(door, 1, witness); + await SubmitAsync(new XChainAccountCreateCommit + { + Account = user.ClassicAddress, + XChainBridge = bridge, + Destination = created.ClassicAddress, + Amount = Drops("20000000"), + SignatureReward = Drops("100"), + }, user); + + await OpenSponsorshipAsync(sponsor, witness); + + XChainAddAccountCreateAttestation attestation = 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); + + await SubmitSponsoredAsync(attestation, witness, sponsor); + + AccountInfo info = await client.AccountInfo(new AccountInfoRequest(created.ClassicAddress)).Typed(); + Assert.AreEqual("20000000", info.AccountData.Balance.Value, "quorum of one creates the account from the door's funds"); + } } From fcc38c03f3d3ffd379a6e01841bd70b9ed4868d2 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Fri, 4 Sep 2026 15:26:15 -0300 Subject: [PATCH 3/4] docs(test): document the bridge and sponsorship test helpers The review's docstring-coverage check counted 52% across the functions this branch touches. Documented the ones where a sentence says something a reader would not get from the name: the four bridge attestation cases, both sponsored ones, and the helpers whose behaviour is not obvious - what the witness signer list is for, what a null destination on an attestation means, why an object count is being taken. Left alone: the MSTest lifecycle members and the one-line amount converters, where a docstring would only restate the name. --- .../transactions/TestISponsoredVaultLoan.cs | 1 + .../transactions/TestIXChainAttestation.cs | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredVaultLoan.cs b/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredVaultLoan.cs index 17426e0c..58cac9b5 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredVaultLoan.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredVaultLoan.cs @@ -69,6 +69,7 @@ public void CheckSponsorAmendment() #region Helpers + /// Submits a setup transaction unsponsored, and fails loudly if the ledger refuses it. private static async Task SubmitPlainAsync(ITransactionRequest tx, XrplWallet signer, string context) { ITransactionRequest autofilled = await client.Autofill(tx); diff --git a/Tests/Xrpl.Tests/Integration/transactions/TestIXChainAttestation.cs b/Tests/Xrpl.Tests/Integration/transactions/TestIXChainAttestation.cs index eff50ab2..38960b44 100644 --- a/Tests/Xrpl.Tests/Integration/transactions/TestIXChainAttestation.cs +++ b/Tests/Xrpl.Tests/Integration/transactions/TestIXChainAttestation.cs @@ -59,12 +59,17 @@ public void CheckXChainBridgeAmendment() private static Currency Iou(string issuer, string value) => new Currency { CurrencyCode = TestCurrencyCode, Issuer = issuer, Value = value }; + /// Submits a setup transaction and fails loudly if the ledger refuses it. private static async Task SubmitAsync(ITransactionRequest tx, XrplWallet signer) { ITransactionRequest autofilled = await client.Autofill(tx); ValidateResult(await client.SubmitAndWait(autofilled, signer, true)); } + /// + /// Makes the door's signer list, which is where rippled looks to + /// decide whether an attestation counts and how many are needed. + /// private static async Task SetWitnessesAsync(XrplWallet door, uint quorum, params XrplWallet[] witnesses) { await SubmitAsync(new SignerListSet @@ -105,6 +110,7 @@ private static async Task SubmitSponsoredAsync(T tx, XrplWallet sponsee, Xrpl $"sponsored {tx.TransactionType} must validate with tesSUCCESS, got {res.Meta?.TransactionResult}"); } + /// What holds of the issuer's currency, zero if no line exists. private static async Task IouBalanceAsync(string holder, string issuer) { AccountLines lines = await client.AccountLines(new AccountLinesRequest(holder)).Typed(); @@ -112,6 +118,7 @@ private static async Task IouBalanceAsync(string holder, string issuer) return line?.BalanceAsNumber ?? 0m; } + /// How many objects of one type the account owns, for asserting a claim id came or went. private static async Task CountObjectsAsync(string account, LedgerEntryType type) { AccountObjects objects = await client.AccountObjects(new AccountObjectsRequest(account) { Type = type }).Typed(); @@ -151,6 +158,11 @@ private static async Task CommitOnIouBridgeAsync() return new IouBridge(door, issuer, witness, user, recipient, bridge); } + /// + /// The unsigned attestation for the commit made in . A null + /// leaves the funds for an explicit XChainClaim instead of + /// having them delivered when quorum is reached. + /// private static XChainAddClaimAttestation ClaimAttestation(IouBridge setup, string destination) => new XChainAddClaimAttestation { Account = setup.Witness.ClassicAddress, @@ -164,6 +176,10 @@ private static async Task CommitOnIouBridgeAsync() XChainClaimID = "1", }; + /// + /// One witness on a quorum of one: the attestation both proves the commit and releases the + /// funds, because it names a destination. + /// [TestMethod] public async Task TestXChainAddClaimAttestation_WithDestination_DeliversOnQuorum() { @@ -178,6 +194,10 @@ public async Task TestXChainAddClaimAttestation_WithDestination_DeliversOnQuorum Assert.AreEqual(0, await CountObjectsAsync(setup.Recipient.ClassicAddress, LedgerEntryType.XChainOwnedClaimID), "the claim id is consumed by the delivery"); } + /// + /// Without a destination on the attestation the funds wait, and the recipient collects them + /// with an explicit XChainClaim carrying a DestinationTag. + /// [TestMethod] public async Task TestXChainClaim_AfterAttestationWithoutDestination_UsesDestinationTag() { @@ -201,6 +221,10 @@ await SubmitAsync(new XChainClaim Assert.AreEqual(0, await CountObjectsAsync(setup.Recipient.ClassicAddress, LedgerEntryType.XChainOwnedClaimID)); } + /// + /// A correctly signed attestation from an account that is not on the door's signer list is + /// refused with tecNO_PERMISSION, and nothing is delivered. + /// [TestMethod] public async Task TestXChainAddClaimAttestation_UnlistedWitness_IsRejected() { @@ -220,6 +244,10 @@ public async Task TestXChainAddClaimAttestation_UnlistedWitness_IsRejected() Assert.AreEqual(0m, await IouBalanceAsync(setup.Recipient.ClassicAddress, setup.Issuer.ClassicAddress)); } + /// + /// Two witnesses, quorum of two: the first attestation is parked on the door in an + /// XChainOwnedCreateAccountClaimID, and the second creates the account from the door's funds. + /// [TestMethod] public async Task TestXChainAddAccountCreateAttestation_TwoWitnesses_CreatesTheAccountOnQuorum() { @@ -300,6 +328,10 @@ await SubmitSponsoredAsync(new XChainClaim Assert.AreEqual(0, await CountObjectsAsync(setup.Recipient.ClassicAddress, LedgerEntryType.XChainOwnedClaimID)); } + /// + /// The account-create attestation with its fee sponsored, the counterpart to the claim + /// attestation above. + /// [TestMethod] public async Task Sponsored_XChainAddAccountCreateAttestation() { From f8c12f3edcf700e601594110fc3c3b8a755a75af Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Fri, 4 Sep 2026 17:14:57 -0300 Subject: [PATCH 4/4] ci(devnet): add TestISponsoredVaultLoan to the default coverage filter The class arrives with this branch, so the filter entry belongs here rather than in a follow-up: the sponsored Vault and Loan cases are exactly the transactions the dashboard scores for XLS-68, and a class the default run omits contributes nothing to it. --- .github/workflows/devnet-coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/devnet-coverage.yml b/.github/workflows/devnet-coverage.yml index e8ec9661..1fa5fd7b 100644 --- a/.github/workflows/devnet-coverage.yml +++ b/.github/workflows/devnet-coverage.yml @@ -38,7 +38,7 @@ on: description: dotnet test --filter expression type: string default: >- - FullyQualifiedName~TestIBatchInnerTypes|FullyQualifiedName~TestISponsoredTypes|FullyQualifiedName~TestIAMMMpt|FullyQualifiedName~TestIXChainAttestation|FullyQualifiedName~TestILoanMultisig|FullyQualifiedName~TestIXChainBridge|FullyQualifiedName~TestIBatch|FullyQualifiedName~TestISponsorship|FullyQualifiedName~TestILedgerStateFix + FullyQualifiedName~TestIBatchInnerTypes|FullyQualifiedName~TestISponsoredTypes|FullyQualifiedName~TestISponsoredVaultLoan|FullyQualifiedName~TestIAMMMpt|FullyQualifiedName~TestIXChainAttestation|FullyQualifiedName~TestILoanMultisig|FullyQualifiedName~TestIXChainBridge|FullyQualifiedName~TestIBatch|FullyQualifiedName~TestISponsorship|FullyQualifiedName~TestILedgerStateFix env: DOTNET_VERSION: '10.0.x'