diff --git a/CHANGES.md b/CHANGES.md index 15d99a6d..7eded054 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 diff --git a/Tests/Xrpl.Tests/Client/TestUNFTInfoAndHistory.cs b/Tests/Xrpl.Tests/Client/TestUNFTInfoAndHistory.cs new file mode 100644 index 00000000..4d16a8ec --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestUNFTInfoAndHistory.cs @@ -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 +{ + /// + /// The two Clio commands that answer "who owns this token" and "what happened to it" - issue #132. + /// + /// + /// + /// Neither had a model, and neither has a substitute. Ownership cannot be read out of + /// nft_sell_offers, 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. + /// + /// + /// The field names here were taken from Clio's own handlers rather than from documentation: + /// NFTInfo.cpp and NFTHistory.cpp. That matters for at least one of them - + /// Clio emits nft_serial while its own source notes the documentation calls it + /// nft_sequence. + /// + /// + [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\""); + } + + /// + /// The answer, read from a body shaped the way Clio writes it. + /// + [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(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 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\""); + } + + /// + /// History entries are the same shape account_tx returns, so the same type reads them. + /// + /// + /// Asserted rather than assumed, because it is the reason no parallel entry type was + /// written: already handles the tx and tx_json + /// envelopes of API v1 and v2, and a second type would be a second place to keep in step + /// with rippled's envelopes. + /// + [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(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( + 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)}"); + } + + /// + /// An answer without a marker is the last page, and that has to be visible. + /// + [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(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); + } + } +} diff --git a/Tests/Xrpl.Tests/Integration/requests/TestINFTClioCommands.cs b/Tests/Xrpl.Tests/Integration/requests/TestINFTClioCommands.cs new file mode 100644 index 00000000..01399ae0 --- /dev/null +++ b/Tests/Xrpl.Tests/Integration/requests/TestINFTClioCommands.cs @@ -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; + +/// +/// What a plain rippled node says when asked for a Clio-only command - issue #132. +/// +/// +/// +/// nft_info and nft_history 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. +/// +/// +/// 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. +/// +/// +[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( + () => 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( + () => client.NFTHistory(new NFTHistoryRequest(TokenId))); + + Assert.IsNotNull(error.Response); + Assert.AreEqual("unknownCmd", error.Response.Error, $"Node said: {error.Message}"); + } +} diff --git a/Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs b/Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs index 8683ffee..02cade88 100644 --- a/Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs +++ b/Tests/Xrpl.Tests/Sugar/TestUAutofillFees.cs @@ -787,6 +787,8 @@ public Task> LedgerEntry(LedgerEntryRequest re public Task> DepositAuthorized(DepositAuthorizedRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> NFTBuyOffers(NFTBuyOffersRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> NFTSellOffers(NFTSellOffersRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> NFTInfo(NFTInfoRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task> NFTHistory(NFTHistoryRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> AccountNFTs(AccountNFTsRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> AmmInfo(AMMInfoRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> Random(CancellationToken cancellationToken = default) => throw new NotSupportedException(); diff --git a/Xrpl/Client/IXrplClient.cs b/Xrpl/Client/IXrplClient.cs index db536f1e..aa968dec 100644 --- a/Xrpl/Client/IXrplClient.cs +++ b/Xrpl/Client/IXrplClient.cs @@ -422,6 +422,30 @@ public void SetNetworkId(uint? networkId) /// An response. Task> NFTSellOffers(NFTSellOffersRequest request, CancellationToken cancellationToken = default); + /// + /// The nft_info method says who owns an NFToken and what it was minted with. + /// + /// + /// A Clio method: a plain rippled node answers unknownCmd, 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 , because a + /// sale leaves the seller's offers in the ledger and the new owner usually has none. + /// + /// An request. + /// An response. + Task> NFTInfo(NFTInfoRequest request, CancellationToken cancellationToken = default); + + /// + /// The nft_history method returns the transactions that touched an NFToken. + /// + /// + /// A Clio method, paginated the way account_tx is: keep passing + /// back until an answer comes without one. + /// + /// An request. + /// An response. + Task> NFTHistory(NFTHistoryRequest request, CancellationToken cancellationToken = default); + /// The account_nfts method returns a list of NFToken objects for the specified account. /// An request. @@ -1156,6 +1180,18 @@ public Task> NFTSellOffers(NFTSellOffersRequest requ return this.GRequest(request, cancellationToken); } + /// + public Task> NFTInfo(NFTInfoRequest request, CancellationToken cancellationToken = default) + { + return this.GRequest(request, cancellationToken); + } + + /// + public Task> NFTHistory(NFTHistoryRequest request, CancellationToken cancellationToken = default) + { + return this.GRequest(request, cancellationToken); + } + /// public Task> NoRippleCheck(NoRippleCheckRequest request, CancellationToken cancellationToken = default) { diff --git a/Xrpl/Models/Methods/NFTHistory.cs b/Xrpl/Models/Methods/NFTHistory.cs new file mode 100644 index 00000000..0a302bc3 --- /dev/null +++ b/Xrpl/Models/Methods/NFTHistory.cs @@ -0,0 +1,124 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +// https://github.com/XRPLF/clio/blob/develop/src/rpc/handlers/NFTHistory.cpp +namespace Xrpl.Models.Methods +{ + /// + /// Response expected from an . + /// + public class NFTHistory : BaseMethodResult + { + /// + /// The token whose history this is. + /// + [JsonPropertyName("nft_id")] + public string NFTokenID { get; set; } + + /// + /// The earliest ledger actually searched. + /// + [JsonPropertyName("ledger_index_min")] + public uint? LedgerIndexMin { get; set; } + + /// + /// The most recent ledger actually searched. + /// + [JsonPropertyName("ledger_index_max")] + public uint? LedgerIndexMax { get; set; } + + /// + /// The limit that was applied. + /// + [JsonPropertyName("limit")] + public uint? Limit { get; set; } + + /// + /// Present when there is more to read; pass it back to continue where this left off. + /// + /// + /// Clio sends an object of ledger and seq here, the same marker + /// account_tx uses. Typed as object for the same reason it is there: the + /// server defines its shape, and a caller's business with it is to hand it back unread. + /// + [JsonPropertyName("marker")] + public object Marker { get; set; } + + /// + /// The transactions that touched this token, newest first unless forward was asked for. + /// + /// + /// The same entries account_tx returns, so the same type reads them - including the + /// tx versus tx_json envelopes of API v1 and v2, which + /// already handles. Match them on the I-interfaces, + /// as with any transaction read back from a ledger. + /// + [JsonPropertyName("transactions")] + public List Transactions { get; set; } + + /// + /// Whether the answer comes from validated ledgers. + /// + [JsonPropertyName("validated")] + public bool? Validated { get; set; } + } + + /// + /// The nft_history method asks what has happened to a token. + /// + /// + /// A Clio method, like : a plain rippled node answers + /// unknownCmd. Paginated the way account_tx is - keep passing + /// back until the answer comes without one. + /// + public class NFTHistoryRequest : BaseLedgerRequest + { + public NFTHistoryRequest(string nft_id) + { + NFTokenID = nft_id; + Command = "nft_history"; + } + + /// + /// The unique identifier of the NFToken whose history to read. + /// + [JsonPropertyName("nft_id")] + public string NFTokenID { get; set; } + + /// + /// The earliest ledger to search. -1 asks for the earliest available. + /// + [JsonPropertyName("ledger_index_min")] + public int? LedgerIndexMin { get; set; } + + /// + /// The most recent ledger to search. -1 asks for the most recent available. + /// + [JsonPropertyName("ledger_index_max")] + public int? LedgerIndexMax { get; set; } + + /// + /// Return transactions as hex strings instead of JSON. + /// + [JsonPropertyName("binary")] + public bool? Binary { get; set; } + + /// + /// Read oldest first instead of newest first. + /// + [JsonPropertyName("forward")] + public bool? Forward { get; set; } + + /// + /// How many transactions to return at most. + /// + [JsonPropertyName("limit")] + public uint? Limit { get; set; } + + /// + /// The marker from a previous answer, to continue from where it stopped. + /// + [JsonPropertyName("marker")] + public object Marker { get; set; } + } +} diff --git a/Xrpl/Models/Methods/NFTInfo.cs b/Xrpl/Models/Methods/NFTInfo.cs new file mode 100644 index 00000000..b828cf8b --- /dev/null +++ b/Xrpl/Models/Methods/NFTInfo.cs @@ -0,0 +1,118 @@ +using System.Text.Json.Serialization; + +// https://github.com/XRPLF/clio/blob/develop/src/rpc/handlers/NFTInfo.cpp +namespace Xrpl.Models.Methods +{ + /// + /// Response expected from an . + /// + public class NFTInfo : BaseMethodResult + { + /// + /// The token this describes. + /// + [JsonPropertyName("nft_id")] + public string NFTokenID { get; set; } + + /// + /// The ledger the answer was read from. + /// + [JsonPropertyName("ledger_index")] + public uint? LedgerIndex { get; set; } + + /// + /// Who holds the token now. + /// + /// + /// The reason this command is worth having. An owner cannot be worked out from + /// : 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 current owner may have made none at all. + /// + [JsonPropertyName("owner")] + public string Owner { get; set; } + + /// + /// Whether the token has been burned, in which case it has no owner any more. + /// + [JsonPropertyName("is_burned")] + public bool? IsBurned { get; set; } + + /// + /// The flags the token was minted with. + /// + [JsonPropertyName("flags")] + public uint? Flags { get; set; } + + /// + /// The issuer's cut of secondary sales, in units of 1/100 000. + /// + [JsonPropertyName("transfer_fee")] + public uint? TransferFee { get; set; } + + /// + /// The account that minted the token. + /// + [JsonPropertyName("issuer")] + public string Issuer { get; set; } + + /// + /// The issuer's own grouping of their tokens. + /// + [JsonPropertyName("nft_taxon")] + public uint? Taxon { get; set; } + + /// + /// The token's sequence within that issuer and taxon. + /// + /// + /// Clio sends this as nft_serial; its own source notes that the documentation calls + /// it nft_sequence. The name here follows what actually arrives on the wire. + /// + [JsonPropertyName("nft_serial")] + public uint? Serial { get; set; } + + /// + /// The URI the token was minted with, as hex. + /// + [JsonPropertyName("uri")] + public string URI { get; set; } + + /// + /// Whether the answer comes from a validated ledger. + /// + [JsonPropertyName("validated")] + public bool? Validated { get; set; } + } + + /// + /// The nft_info method asks who owns a token and what it was minted with. + /// + /// + /// + /// A Clio method, not a rippled one. A plain rippled node answers unknownCmd, which + /// arrives as an ordinary node error rather than something special - a caller who needs to work + /// against both can catch it and fall back. + /// + /// + /// There is no substitute for it on a rippled node. Ownership cannot be read out of + /// nft_sell_offers: a sale leaves the seller's offers in the ledger, so they keep being + /// returned by an account that no longer owns the token, and the new owner usually has no + /// offers at all - which is exactly the state a token is in right after being bought. + /// + /// + public class NFTInfoRequest : BaseLedgerRequest + { + public NFTInfoRequest(string nft_id) + { + NFTokenID = nft_id; + Command = "nft_info"; + } + + /// + /// The unique identifier of the NFToken to describe. + /// + [JsonPropertyName("nft_id")] + public string NFTokenID { get; set; } + } +}