diff --git a/include/xrpl/protocol/NFTSyntheticSerializer.h b/include/xrpl/protocol/NFTSyntheticSerializer.h index e57b3ff71c..cb33744485 100644 --- a/include/xrpl/protocol/NFTSyntheticSerializer.h +++ b/include/xrpl/protocol/NFTSyntheticSerializer.h @@ -28,6 +28,8 @@ namespace ripple { +namespace RPC { + /** Adds common synthetic fields to transaction-related JSON responses @@ -40,6 +42,7 @@ insertNFTSyntheticInJson( TxMeta const&); /** @} */ +} // namespace RPC } // namespace ripple #endif diff --git a/src/libxrpl/protocol/NFTSyntheticSerializer.cpp b/src/libxrpl/protocol/NFTSyntheticSerializer.cpp index 0c0a657512..64fa9319de 100644 --- a/src/libxrpl/protocol/NFTSyntheticSerializer.cpp +++ b/src/libxrpl/protocol/NFTSyntheticSerializer.cpp @@ -28,6 +28,7 @@ #include namespace ripple { +namespace RPC { void insertNFTSyntheticInJson( @@ -39,4 +40,5 @@ insertNFTSyntheticInJson( insertNFTokenOfferID(response[jss::meta], transaction, transactionMeta); } +} // namespace RPC } // namespace ripple diff --git a/src/test/rpc/Subscribe_test.cpp b/src/test/rpc/Subscribe_test.cpp index 32296c5d0a..e414b60f93 100644 --- a/src/test/rpc/Subscribe_test.cpp +++ b/src/test/rpc/Subscribe_test.cpp @@ -1354,6 +1354,225 @@ public: })); } + void + testNFToken(FeatureBitset features) + { + // `nftoken_id` is added for `transaction` stream in the `subscribe` + // response for NFTokenMint and NFTokenAcceptOffer. + // + // `nftoken_ids` is added for `transaction` stream in the `subscribe` + // response for NFTokenCancelOffer + // + // `offer_id` is added for `transaction` stream in the `subscribe` + // response for NFTokenCreateOffer + // + // The values of these fields are dependent on the NFTokenID/OfferID + // changed in its corresponding transaction. We want to validate each + // response to make sure the synethic fields hold the right values. + + testcase("Test synthetic fields from Subscribe response"); + + using namespace test::jtx; + using namespace std::chrono_literals; + + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const broker{"broker"}; + + Env env{*this, features}; + env.fund(XRP(10000), alice, bob, broker); + env.close(); + + auto wsc = test::makeWSClient(env.app().config()); + Json::Value stream; + stream[jss::streams] = Json::arrayValue; + stream[jss::streams].append("transactions"); + auto jv = wsc->invoke("subscribe", stream); + + // Verify `nftoken_id` value equals to the NFTokenID that was + // changed in the most recent NFTokenMint or NFTokenAcceptOffer + // transaction + auto verifyNFTokenID = [&](uint256 const& actualNftID) { + BEAST_EXPECT(wsc->findMsg(5s, [&](auto const& jv) { + uint256 nftID; + BEAST_EXPECT( + nftID.parseHex(jv[jss::meta][jss::nftoken_id].asString())); + return nftID == actualNftID; + })); + }; + + // Verify `nftoken_ids` value equals to the NFTokenIDs that were + // changed in the most recent NFTokenCancelOffer transaction + auto verifyNFTokenIDsInCancelOffer = + [&](std::vector actualNftIDs) { + BEAST_EXPECT(wsc->findMsg(5s, [&](auto const& jv) { + std::vector metaIDs; + std::transform( + jv[jss::meta][jss::nftoken_ids].begin(), + jv[jss::meta][jss::nftoken_ids].end(), + std::back_inserter(metaIDs), + [this](Json::Value id) { + uint256 nftID; + BEAST_EXPECT(nftID.parseHex(id.asString())); + return nftID; + }); + // Sort both array to prepare for comparison + std::sort(metaIDs.begin(), metaIDs.end()); + std::sort(actualNftIDs.begin(), actualNftIDs.end()); + + // Make sure the expect number of NFTs is correct + BEAST_EXPECT(metaIDs.size() == actualNftIDs.size()); + + // Check the value of NFT ID in the meta with the + // actual values + for (size_t i = 0; i < metaIDs.size(); ++i) + BEAST_EXPECT(metaIDs[i] == actualNftIDs[i]); + return true; + })); + }; + + // Verify `offer_id` value equals to the offerID that was + // changed in the most recent NFTokenCreateOffer tx + auto verifyNFTokenOfferID = [&](uint256 const& offerID) { + BEAST_EXPECT(wsc->findMsg(5s, [&](auto const& jv) { + uint256 metaOfferID; + BEAST_EXPECT(metaOfferID.parseHex( + jv[jss::meta][jss::offer_id].asString())); + return metaOfferID == offerID; + })); + }; + + // Check new fields in tx meta when for all NFTtransactions + { + // Alice mints 2 NFTs + // Verify the NFTokenIDs are correct in the NFTokenMint tx meta + uint256 const nftId1{ + token::getNextID(env, alice, 0u, tfTransferable)}; + env(token::mint(alice, 0u), txflags(tfTransferable)); + env.close(); + verifyNFTokenID(nftId1); + + uint256 const nftId2{ + token::getNextID(env, alice, 0u, tfTransferable)}; + env(token::mint(alice, 0u), txflags(tfTransferable)); + env.close(); + verifyNFTokenID(nftId2); + + // Alice creates one sell offer for each NFT + // Verify the offer indexes are correct in the NFTokenCreateOffer tx + // meta + uint256 const aliceOfferIndex1 = + keylet::nftoffer(alice, env.seq(alice)).key; + env(token::createOffer(alice, nftId1, drops(1)), + txflags(tfSellNFToken)); + env.close(); + verifyNFTokenOfferID(aliceOfferIndex1); + + uint256 const aliceOfferIndex2 = + keylet::nftoffer(alice, env.seq(alice)).key; + env(token::createOffer(alice, nftId2, drops(1)), + txflags(tfSellNFToken)); + env.close(); + verifyNFTokenOfferID(aliceOfferIndex2); + + // Alice cancels two offers she created + // Verify the NFTokenIDs are correct in the NFTokenCancelOffer tx + // meta + env(token::cancelOffer( + alice, {aliceOfferIndex1, aliceOfferIndex2})); + env.close(); + verifyNFTokenIDsInCancelOffer({nftId1, nftId2}); + + // Bobs creates a buy offer for nftId1 + // Verify the offer id is correct in the NFTokenCreateOffer tx meta + auto const bobBuyOfferIndex = + keylet::nftoffer(bob, env.seq(bob)).key; + env(token::createOffer(bob, nftId1, drops(1)), token::owner(alice)); + env.close(); + verifyNFTokenOfferID(bobBuyOfferIndex); + + // Alice accepts bob's buy offer + // Verify the NFTokenID is correct in the NFTokenAcceptOffer tx meta + env(token::acceptBuyOffer(alice, bobBuyOfferIndex)); + env.close(); + verifyNFTokenID(nftId1); + } + + // Check `nftoken_ids` in brokered mode + { + // Alice mints a NFT + uint256 const nftId{ + token::getNextID(env, alice, 0u, tfTransferable)}; + env(token::mint(alice, 0u), txflags(tfTransferable)); + env.close(); + verifyNFTokenID(nftId); + + // Alice creates sell offer and set broker as destination + uint256 const offerAliceToBroker = + keylet::nftoffer(alice, env.seq(alice)).key; + env(token::createOffer(alice, nftId, drops(1)), + token::destination(broker), + txflags(tfSellNFToken)); + env.close(); + verifyNFTokenOfferID(offerAliceToBroker); + + // Bob creates buy offer + uint256 const offerBobToBroker = + keylet::nftoffer(bob, env.seq(bob)).key; + env(token::createOffer(bob, nftId, drops(1)), token::owner(alice)); + env.close(); + verifyNFTokenOfferID(offerBobToBroker); + + // Check NFTokenID meta for NFTokenAcceptOffer in brokered mode + env(token::brokerOffers( + broker, offerBobToBroker, offerAliceToBroker)); + env.close(); + verifyNFTokenID(nftId); + } + + // Check if there are no duplicate nft id in Cancel transactions where + // multiple offers are cancelled for the same NFT + { + // Alice mints a NFT + uint256 const nftId{ + token::getNextID(env, alice, 0u, tfTransferable)}; + env(token::mint(alice, 0u), txflags(tfTransferable)); + env.close(); + verifyNFTokenID(nftId); + + // Alice creates 2 sell offers for the same NFT + uint256 const aliceOfferIndex1 = + keylet::nftoffer(alice, env.seq(alice)).key; + env(token::createOffer(alice, nftId, drops(1)), + txflags(tfSellNFToken)); + env.close(); + verifyNFTokenOfferID(aliceOfferIndex1); + + uint256 const aliceOfferIndex2 = + keylet::nftoffer(alice, env.seq(alice)).key; + env(token::createOffer(alice, nftId, drops(1)), + txflags(tfSellNFToken)); + env.close(); + verifyNFTokenOfferID(aliceOfferIndex2); + + // Make sure the metadata only has 1 nft id, since both offers are + // for the same nft + env(token::cancelOffer( + alice, {aliceOfferIndex1, aliceOfferIndex2})); + env.close(); + verifyNFTokenIDsInCancelOffer({nftId}); + } + + if (features[featureNFTokenMintOffer]) + { + uint256 const aliceMintWithOfferIndex1 = + keylet::nftoffer(alice, env.seq(alice)).key; + env(token::mint(alice), token::amount(XRP(0))); + env.close(); + verifyNFTokenOfferID(aliceMintWithOfferIndex1); + } + } + void run() override { @@ -1373,6 +1592,8 @@ public: testSubByUrl(); testHistoryTxStream(); testSubBookChanges(); + testNFToken(all); + testNFToken(all - featureNFTokenMintOffer); } }; diff --git a/src/xrpld/app/misc/NetworkOPs.cpp b/src/xrpld/app/misc/NetworkOPs.cpp index 1b1bea3ad9..a7ddbe912c 100644 --- a/src/xrpld/app/misc/NetworkOPs.cpp +++ b/src/xrpld/app/misc/NetworkOPs.cpp @@ -63,6 +63,7 @@ #include #include #include +#include #include #include #include @@ -3258,6 +3259,7 @@ NetworkOPsImp::transJson( jvObj[jss::meta] = meta->get().getJson(JsonOptions::none); RPC::insertDeliveredAmount( jvObj[jss::meta], *ledger, transaction, meta->get()); + RPC::insertNFTSyntheticInJson(jvObj, transaction, meta->get()); RPC::insertMPTokenIssuanceID( jvObj[jss::meta], transaction, meta->get()); } diff --git a/src/xrpld/rpc/handlers/AccountTx.cpp b/src/xrpld/rpc/handlers/AccountTx.cpp index 26c8065edf..d5df40303b 100644 --- a/src/xrpld/rpc/handlers/AccountTx.cpp +++ b/src/xrpld/rpc/handlers/AccountTx.cpp @@ -348,7 +348,7 @@ populateJsonResponse( txnMeta->getJson(JsonOptions::include_date); insertDeliveredAmount( jvObj[jss::meta], context, txn, *txnMeta); - insertNFTSyntheticInJson(jvObj, sttx, *txnMeta); + RPC::insertNFTSyntheticInJson(jvObj, sttx, *txnMeta); RPC::insertMPTokenIssuanceID( jvObj[jss::meta], sttx, *txnMeta); } diff --git a/src/xrpld/rpc/handlers/Tx.cpp b/src/xrpld/rpc/handlers/Tx.cpp index 3db71d9002..d43a699ab3 100644 --- a/src/xrpld/rpc/handlers/Tx.cpp +++ b/src/xrpld/rpc/handlers/Tx.cpp @@ -270,7 +270,7 @@ populateJsonResponse( response[jss::meta] = meta->getJson(JsonOptions::none); insertDeliveredAmount( response[jss::meta], context, result.txn, *meta); - insertNFTSyntheticInJson(response, sttx, *meta); + RPC::insertNFTSyntheticInJson(response, sttx, *meta); RPC::insertMPTokenIssuanceID(response[jss::meta], sttx, *meta); } }