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"); + } }