diff --git a/.github/workflows/devnet-coverage.yml b/.github/workflows/devnet-coverage.yml
index 45c766c3..1fa5fd7b 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~TestISponsoredVaultLoan|FullyQualifiedName~TestIAMMMpt|FullyQualifiedName~TestIXChainAttestation|FullyQualifiedName~TestILoanMultisig|FullyQualifiedName~TestIXChainBridge|FullyQualifiedName~TestIBatch|FullyQualifiedName~TestISponsorship|FullyQualifiedName~TestILedgerStateFix
env:
DOTNET_VERSION: '10.0.x'
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..58cac9b5
--- /dev/null
+++ b/Tests/Xrpl.Tests/Integration/transactions/TestISponsoredVaultLoan.cs
@@ -0,0 +1,473 @@
+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
+
+ /// 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);
+ 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..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
@@ -77,6 +82,35 @@ 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}");
+ }
+
+ /// 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();
@@ -84,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();
@@ -123,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,
@@ -136,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()
{
@@ -150,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()
{
@@ -173,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()
{
@@ -192,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()
{
@@ -239,4 +295,83 @@ 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));
+ }
+
+ ///
+ /// The account-create attestation with its fee sponsored, the counterpart to the claim
+ /// attestation above.
+ ///
+ [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");
+ }
}