Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ The raw-response work, all five levels landing together. The problem: a consumer

The entries below are grouped by what changed, not by the order the levels were built in. The short version of what will not compile is in the table above.

* **`nft_info` and `nft_history`, the two Clio commands NFT work needs** (#132). Neither had a model, and neither has a substitute on a rippled node.
* **an owner cannot be read out of `nft_sell_offers`**, which is the natural guess. Selling a token does not remove offers for it from the ledger, so offers made by a previous owner keep being returned long after they can no longer be accepted, and the new owner has usually made none - which is exactly the state a token is in right after being bought. Taking the owner from the first offer shows the wrong account
* field names were taken from Clio's own handlers rather than from documentation, and one of them differs: Clio emits `nft_serial`, and its own source notes that the docs call it `nft_sequence`. A test pins the name that arrives on the wire
* history entries are the same shape `account_tx` returns, so `TransactionSummary` reads them - envelopes of API v1 and v2 included - rather than a second type that would have to be kept in step with the same rippled envelopes. Read them through the `I` interfaces, as with any transaction from a ledger
* both are Clio-only. A plain rippled node answers `unknownCmd`, which arrives as an ordinary `RippledException` carrying that code, so a consumer who has to work against both can recognise it and fall back. There is an integration test for that, against the rippled stand this suite runs on
* **A failed submission arrives as something a caller can act on: `TransactionFailedException`** (#131). `SubmitAndWait` threw a bare `RippleException` whose only content was a sentence, so telling `tecINSUFFICIENT_PAYMENT` from `tecEXPIRED` meant reading the text - and the classes of code mean entirely different things: `tem` is a malformed request to fix, `tec` was applied with the fee taken, `ter` may work later. The hash was not available at all, and the hash is exactly what is wanted after a `tec`: the transaction is in a ledger, and showing it is the first thing anyone does.
* the new type carries `EngineResult`, `Hash`, `Result` (the validated transaction and its metadata) and `ReachedLedger`
* **nothing breaks.** It derives from `RippleException` and the message is byte-for-byte what it was - deliberately not improved while the code was open. Four integration tests in this repository assert that text word for word and needed no change, which is the same claim made from the other side
Expand Down
202 changes: 202 additions & 0 deletions Tests/Xrpl.Tests/Client/TestUNFTInfoAndHistory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;

using System.Text.Json;

using Xrpl.Client.Json;
using Xrpl.Models.Common;
using Xrpl.Models.Ledger;
using Xrpl.Models.Methods;
using Xrpl.Models.Transactions;

namespace Xrpl.Tests.ClientLib
{
/// <summary>
/// The two Clio commands that answer "who owns this token" and "what happened to it" - issue #132.
/// </summary>
/// <remarks>
/// <para>
/// Neither had a model, and neither has a substitute. Ownership cannot be read out of
/// <c>nft_sell_offers</c>, which is the natural guess: a sale does not remove offers for the
/// token from the ledger, so offers made by a previous owner keep being returned after they can
/// no longer be accepted, and the new owner has usually made none - which is exactly the state
/// a token is in immediately after being bought.
/// </para>
/// <para>
/// The field names here were taken from Clio's own handlers rather than from documentation:
/// <c>NFTInfo.cpp</c> and <c>NFTHistory.cpp</c>. That matters for at least one of them -
/// Clio emits <c>nft_serial</c> while its own source notes the documentation calls it
/// <c>nft_sequence</c>.
/// </para>
/// </remarks>
[TestClass]
public class TestUNFTInfoAndHistory
{
private const string TokenId = "00190000E78F76A49DD9158FA85DA4AAD95C0767303CC4611D73BB4300C989A8";

[TestMethod]
public void TestUNFTInfoRequestAsksForWhatClioExpects()
{
NFTInfoRequest request = new NFTInfoRequest(TokenId)
{
LedgerIndex = new LedgerIndex(LedgerIndexType.Validated),
};

string json = JsonSerializer.Serialize(request, XrplJsonOptions.Default);

StringAssert.Contains(json, "\"command\":\"nft_info\"");
StringAssert.Contains(json, "\"nft_id\":\"" + TokenId + "\"");
StringAssert.Contains(json, "\"ledger_index\":\"validated\"");
}

/// <summary>
/// The answer, read from a body shaped the way Clio writes it.
/// </summary>
[TestMethod]
public void TestUNFTInfoReadsEveryFieldClioSends()
{
const string body = """
{
"nft_id": "00190000E78F76A49DD9158FA85DA4AAD95C0767303CC4611D73BB4300C989A8",
"ledger_index": 270,
"owner": "rG9gdNhhCXhK1UVLbaBHzXHZzYrDVJHbAM",
"is_burned": false,
"flags": 25,
"transfer_fee": 314,
"issuer": "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh",
"nft_taxon": 0,
"nft_serial": 12345,
"uri": "697066733A2F2F62616679626569676479727A74357366703775646D3768753736",
"validated": true
}
""";

NFTInfo info = JsonSerializer.Deserialize<NFTInfo>(body, XrplJsonOptions.Default);

Assert.AreEqual(TokenId, info.NFTokenID);
Assert.AreEqual(270u, info.LedgerIndex);
Assert.AreEqual("rG9gdNhhCXhK1UVLbaBHzXHZzYrDVJHbAM", info.Owner);
Assert.IsFalse(info.IsBurned.Value, "This one is alive; a burned token has no owner to report.");
Assert.AreEqual(25u, info.Flags);
Assert.AreEqual(314u, info.TransferFee);
Assert.AreEqual("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", info.Issuer);
Assert.AreEqual(0u, info.Taxon);
Assert.AreEqual(12345u, info.Serial, "Clio sends this as nft_serial, whatever the documentation calls it.");
Assert.IsFalse(string.IsNullOrEmpty(info.URI));
Assert.IsTrue(info.Validated.Value);

// The other half of the claim, and the half the assertions above cannot make: that
// nothing Clio sends was missed. Reading eleven properties correctly says nothing about
// a twelfth quietly landing in UnknownFields - which is the bar this repository already
// set for modelled fields, a property declared AND the field gone from here.
Assert.IsTrue(
info.UnknownFields is null || info.UnknownFields.Count == 0,
$"nft_info fields the model does not declare: {Describe(info.UnknownFields)}");
}

private static string Describe(System.Collections.Generic.IDictionary<string, System.Text.Json.JsonElement> unknown) =>
unknown is null ? "none" : string.Join(", ", unknown.Keys);

[TestMethod]
public void TestUNFTHistoryRequestCarriesItsPagination()
{
NFTHistoryRequest request = new NFTHistoryRequest(TokenId)
{
LedgerIndexMin = -1,
LedgerIndexMax = -1,
Limit = 200,
Forward = true,
Marker = new { ledger = 270, seq = 1 },
};

string json = JsonSerializer.Serialize(request, XrplJsonOptions.Default);

StringAssert.Contains(json, "\"command\":\"nft_history\"");
StringAssert.Contains(json, "\"nft_id\":\"" + TokenId + "\"");
StringAssert.Contains(json, "\"ledger_index_min\":-1");
StringAssert.Contains(json, "\"ledger_index_max\":-1");
StringAssert.Contains(json, "\"limit\":200");
StringAssert.Contains(json, "\"forward\":true");
StringAssert.Contains(json, "\"marker\"");
}

/// <summary>
/// History entries are the same shape <c>account_tx</c> returns, so the same type reads them.
/// </summary>
/// <remarks>
/// Asserted rather than assumed, because it is the reason no parallel entry type was
/// written: <see cref="TransactionSummary"/> already handles the <c>tx</c> and <c>tx_json</c>
/// envelopes of API v1 and v2, and a second type would be a second place to keep in step
/// with rippled's envelopes.
/// </remarks>
[TestMethod]
public void TestUNFTHistoryReadsItsTransactionsAndMarker()
{
const string body = """
{
"nft_id": "00190000E78F76A49DD9158FA85DA4AAD95C0767303CC4611D73BB4300C989A8",
"ledger_index_min": 3,
"ledger_index_max": 270,
"limit": 2,
"marker": { "ledger": 265, "seq": 1 },
"transactions": [
{
"meta": { "TransactionResult": "tesSUCCESS" },
"tx_json": {
"Account": "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh",
"TransactionType": "NFTokenMint",
"NFTokenTaxon": 0
},
"hash": "5F8A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D4E5F60718293A4B5C6D7E8",
"ledger_index": 270,
"validated": true
}
],
"validated": true
}
""";

NFTHistory history = JsonSerializer.Deserialize<NFTHistory>(body, XrplJsonOptions.Default);

Assert.AreEqual(TokenId, history.NFTokenID);
Assert.AreEqual(3u, history.LedgerIndexMin);
Assert.AreEqual(270u, history.LedgerIndexMax);
Assert.IsNotNull(history.Marker, "A marker means there is more to read, and it must survive to be handed back.");
Assert.IsNotNull(history.Transactions);
Assert.AreEqual(1, history.Transactions.Count);

TransactionSummary entry = history.Transactions[0];
Assert.AreEqual("tesSUCCESS", entry.Meta?.TransactionResult);
Assert.IsNotNull(entry.Transaction, "The tx_json envelope must have been read, same as in account_tx.");
Assert.IsInstanceOfType<INFTokenMint>(
entry.Transaction,
"History is read through the I-interfaces; the request type never matches what a ledger sends.");

Assert.IsTrue(
history.UnknownFields is null || history.UnknownFields.Count == 0,
$"nft_history fields the model does not declare: {Describe(history.UnknownFields)}");
}

/// <summary>
/// An answer without a marker is the last page, and that has to be visible.
/// </summary>
[TestMethod]
public void TestUNFTHistoryWithoutAMarkerIsTheLastPage()
{
const string body = """
{
"nft_id": "00190000E78F76A49DD9158FA85DA4AAD95C0767303CC4611D73BB4300C989A8",
"ledger_index_min": 3,
"ledger_index_max": 270,
"transactions": [],
"validated": true
}
""";

NFTHistory history = JsonSerializer.Deserialize<NFTHistory>(body, XrplJsonOptions.Default);

Assert.IsNull(history.Marker, "Without this a caller cannot tell the last page from a page that happens to be empty.");
Assert.IsNotNull(history.Transactions);
Assert.AreEqual(0, history.Transactions.Count);
}
}
}
66 changes: 66 additions & 0 deletions Tests/Xrpl.Tests/Integration/requests/TestINFTClioCommands.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using System;
using System.Threading.Tasks;

using Microsoft.VisualStudio.TestTools.UnitTesting;

using Xrpl.Client;
using Xrpl.Client.Exceptions;
using Xrpl.Models.Methods;

namespace XrplTests.Xrpl.ClientLib.Integration;

/// <summary>
/// What a plain rippled node says when asked for a Clio-only command - issue #132.
/// </summary>
/// <remarks>
/// <para>
/// <c>nft_info</c> and <c>nft_history</c> are served by Clio, not by rippled, so the stand this
/// suite runs against cannot answer them. That is worth a test rather than a gap: a consumer who
/// needs to work against both has to be able to recognise the refusal and fall back to their own
/// crawl, and what they can recognise it by is exactly what is asserted here.
/// </para>
/// <para>
/// The shape of the answers themselves is covered by unit tests built from Clio's own handlers.
/// This is the other half - that asking a node which does not serve them fails in a way that says
/// so, instead of looking like a network problem or an empty result.
/// </para>
/// </remarks>
[TestClass]
public class TestINFTClioCommands
{
private const string TokenId = "00190000E78F76A49DD9158FA85DA4AAD95C0767303CC4611D73BB4300C989A8";

private static IXrplClient client;

[ClassInitialize]
public static async Task ClassInitializeAsync(TestContext testContext)
{
client = await IntegrationTestConfig.CreateClientAsync(TestNodeType.Standalone);
}

[ClassCleanup]
public static void ClassCleanup() => client?.Dispose();

[TestMethod]
public async Task TestINFTInfoOnARippledNodeIsRefusedRecognisably()
{
RippledException error = await Assert.ThrowsExactlyAsync<RippledException>(
() => client.NFTInfo(new NFTInfoRequest(TokenId)));

Assert.IsNotNull(error.Response, "The node's own answer must reach the caller, not just a message.");
Assert.AreEqual(
"unknownCmd",
error.Response.Error,
$"A caller falling back to their own crawl needs to recognise this by the error code. Node said: {error.Message}");
}

[TestMethod]
public async Task TestINFTHistoryOnARippledNodeIsRefusedRecognisably()
{
RippledException error = await Assert.ThrowsExactlyAsync<RippledException>(
() => client.NFTHistory(new NFTHistoryRequest(TokenId)));

Assert.IsNotNull(error.Response);
Assert.AreEqual("unknownCmd", error.Response.Error, $"Node said: {error.Message}");
}
}
2 changes: 2 additions & 0 deletions Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,8 @@ public Task<XrplResponse<LedgerEntryResponse>> LedgerEntry(LedgerEntryRequest re
public Task<XrplResponse<DepositAuthorized>> DepositAuthorized(DepositAuthorizedRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<XrplResponse<NFTBuyOffers>> NFTBuyOffers(NFTBuyOffersRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<XrplResponse<NFTSellOffers>> NFTSellOffers(NFTSellOffersRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<XrplResponse<NFTInfo>> NFTInfo(NFTInfoRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<XrplResponse<NFTHistory>> NFTHistory(NFTHistoryRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<XrplResponse<AccountNFTs>> AccountNFTs(AccountNFTsRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<XrplResponse<AMMInfoResponse>> AmmInfo(AMMInfoRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<XrplResponse<object>> Random(CancellationToken cancellationToken = default) => throw new NotSupportedException();
Expand Down
36 changes: 36 additions & 0 deletions Xrpl/Client/IXrplClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,30 @@ public void SetNetworkId(uint? networkId)
/// <returns>An <see cref="Models.Methods.NFTSellOffers"/> response.</returns>
Task<XrplResponse<NFTSellOffers>> NFTSellOffers(NFTSellOffersRequest request, CancellationToken cancellationToken = default);

/// <summary>
/// The nft_info method says who owns an NFToken and what it was minted with.
/// </summary>
/// <remarks>
/// A Clio method: a plain rippled node answers <c>unknownCmd</c>, which arrives as an
/// ordinary node error so a caller can catch it and fall back. There is no substitute for
/// it on rippled - an owner cannot be read out of <see cref="NFTSellOffers"/>, because a
/// sale leaves the seller's offers in the ledger and the new owner usually has none.
/// </remarks>
/// <param name="request">An <see cref="NFTInfoRequest"/> request.</param>
/// <returns>An <see cref="Models.Methods.NFTInfo"/> response.</returns>
Task<XrplResponse<NFTInfo>> NFTInfo(NFTInfoRequest request, CancellationToken cancellationToken = default);

/// <summary>
/// The nft_history method returns the transactions that touched an NFToken.
/// </summary>
/// <remarks>
/// A Clio method, paginated the way <c>account_tx</c> is: keep passing
/// <see cref="Models.Methods.NFTHistory.Marker"/> back until an answer comes without one.
/// </remarks>
/// <param name="request">An <see cref="NFTHistoryRequest"/> request.</param>
/// <returns>An <see cref="Models.Methods.NFTHistory"/> response.</returns>
Task<XrplResponse<NFTHistory>> NFTHistory(NFTHistoryRequest request, CancellationToken cancellationToken = default);


/// <summary> The account_nfts method returns a list of NFToken objects for the specified account.</summary>
/// <param name="request">An <see cref="AccountNFTsRequest"/> request.</param>
Expand Down Expand Up @@ -1156,6 +1180,18 @@ public Task<XrplResponse<NFTSellOffers>> NFTSellOffers(NFTSellOffersRequest requ
return this.GRequest<NFTSellOffers, NFTSellOffersRequest>(request, cancellationToken);
}

/// <inheritdoc />
public Task<XrplResponse<NFTInfo>> NFTInfo(NFTInfoRequest request, CancellationToken cancellationToken = default)
{
return this.GRequest<NFTInfo, NFTInfoRequest>(request, cancellationToken);
}

/// <inheritdoc />
public Task<XrplResponse<NFTHistory>> NFTHistory(NFTHistoryRequest request, CancellationToken cancellationToken = default)
{
return this.GRequest<NFTHistory, NFTHistoryRequest>(request, cancellationToken);
}

/// <inheritdoc />
public Task<XrplResponse<NoRippleCheck>> NoRippleCheck(NoRippleCheckRequest request, CancellationToken cancellationToken = default)
{
Expand Down
Loading
Loading