diff --git a/CHANGELOG.md b/CHANGELOG.md index b350aa4af8b..57d47d70761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ * [#12089](https://github.com/cosmos/cosmos-sdk/pull/12089) Mark the `TipDecorator` as beta, don't include it in simapp by default. * [#12153](https://github.com/cosmos/cosmos-sdk/pull/12153) Add a new `NewSimulationManagerFromAppModules` constructor, to simplify simulation wiring. +* [#12187](https://github.com/cosmos/cosmos-sdk/pull/12187) Add batch operation for x/nft module. ### API Breaking Changes diff --git a/x/nft/keeper/nft.go b/x/nft/keeper/nft.go index 336aff41b67..c4b6e58bb43 100644 --- a/x/nft/keeper/nft.go +++ b/x/nft/keeper/nft.go @@ -17,6 +17,14 @@ func (k Keeper) Mint(ctx sdk.Context, token nft.NFT, receiver sdk.AccAddress) er return sdkerrors.Wrap(nft.ErrNFTExists, token.Id) } + k.mintWithNoCheck(ctx, token, receiver) + return nil +} + +// mintWithNoCheck defines a method for minting a new nft +// Note: this method does not check whether the class already exists in nft. +// The upper-layer application needs to check it when it needs to use it. +func (k Keeper) mintWithNoCheck(ctx sdk.Context, token nft.NFT, receiver sdk.AccAddress) { k.setNFT(ctx, token) k.setOwner(ctx, token.ClassId, token.Id, receiver) k.incrTotalSupply(ctx, token.ClassId) @@ -26,7 +34,6 @@ func (k Keeper) Mint(ctx sdk.Context, token nft.NFT, receiver sdk.AccAddress) er Id: token.Id, Owner: receiver.String(), }) - return nil } // Burn defines a method for burning a nft from a specific account. @@ -40,6 +47,14 @@ func (k Keeper) Burn(ctx sdk.Context, classID string, nftID string) error { return sdkerrors.Wrap(nft.ErrNFTNotExists, nftID) } + k.burnWithNoCheck(ctx, classID, nftID) + return nil +} + +// burnWithNoCheck defines a method for burning a nft from a specific account. +// Note: this method does not check whether the class already exists in nft. +// The upper-layer application needs to check it when it needs to use it +func (k Keeper) burnWithNoCheck(ctx sdk.Context, classID string, nftID string) error { owner := k.GetOwner(ctx, classID, nftID) nftStore := k.getNFTStore(ctx, classID) nftStore.Delete([]byte(nftID)) @@ -64,10 +79,17 @@ func (k Keeper) Update(ctx sdk.Context, token nft.NFT) error { if !k.HasNFT(ctx, token.ClassId, token.Id) { return sdkerrors.Wrap(nft.ErrNFTNotExists, token.Id) } - k.setNFT(ctx, token) + k.updateWithNoCheck(ctx, token) return nil } +// Update defines a method for updating an exist nft +// Note: this method does not check whether the class already exists in nft. +// The upper-layer application needs to check it when it needs to use it +func (k Keeper) updateWithNoCheck(ctx sdk.Context, token nft.NFT) { + k.setNFT(ctx, token) +} + // Transfer defines a method for sending a nft from one account to another account. // Note: When the upper module uses this method, it needs to authenticate nft func (k Keeper) Transfer(ctx sdk.Context, @@ -83,6 +105,18 @@ func (k Keeper) Transfer(ctx sdk.Context, return sdkerrors.Wrap(nft.ErrNFTNotExists, nftID) } + k.transferWithNoCheck(ctx, classID, nftID, receiver) + return nil +} + +// Transfer defines a method for sending a nft from one account to another account. +// Note: this method does not check whether the class already exists in nft. +// The upper-layer application needs to check it when it needs to use it +func (k Keeper) transferWithNoCheck(ctx sdk.Context, + classID string, + nftID string, + receiver sdk.AccAddress, +) error { owner := k.GetOwner(ctx, classID, nftID) k.deleteOwner(ctx, classID, nftID, owner) k.setOwner(ctx, classID, nftID, receiver) diff --git a/x/nft/keeper/nft_batch.go b/x/nft/keeper/nft_batch.go new file mode 100644 index 00000000000..936d2074e28 --- /dev/null +++ b/x/nft/keeper/nft_batch.go @@ -0,0 +1,84 @@ +package keeper + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" + "github.com/cosmos/cosmos-sdk/x/nft" +) + +// BatchMint defines a method for minting a batch of nfts +func (k Keeper) BatchMint(ctx sdk.Context, + tokens []nft.NFT, + receiver sdk.AccAddress, +) error { + checked := make(map[string]bool, len(tokens)) + for _, token := range tokens { + if !checked[token.ClassId] && !k.HasClass(ctx, token.ClassId) { + return sdkerrors.Wrap(nft.ErrClassNotExists, token.ClassId) + } + + if k.HasNFT(ctx, token.ClassId, token.Id) { + return sdkerrors.Wrap(nft.ErrNFTExists, token.Id) + } + + checked[token.ClassId] = true + k.mintWithNoCheck(ctx, token, receiver) + } + return nil +} + +// BatchBurn defines a method for burning a batch of nfts from a specific classID. +// Note: When the upper module uses this method, it needs to authenticate nft +func (k Keeper) BatchBurn(ctx sdk.Context, classID string, nftIDs []string) error { + if !k.HasClass(ctx, classID) { + return sdkerrors.Wrap(nft.ErrClassNotExists, classID) + } + for _, nftID := range nftIDs { + if !k.HasNFT(ctx, classID, nftID) { + return sdkerrors.Wrap(nft.ErrNFTNotExists, nftID) + } + if err := k.burnWithNoCheck(ctx, classID, nftID); err != nil { + return err + } + } + return nil +} + +// BatchUpdate defines a method for updating a batch of exist nfts +// Note: When the upper module uses this method, it needs to authenticate nft +func (k Keeper) BatchUpdate(ctx sdk.Context, tokens []nft.NFT) error { + checked := make(map[string]bool, len(tokens)) + for _, token := range tokens { + if !checked[token.ClassId] && !k.HasClass(ctx, token.ClassId) { + return sdkerrors.Wrap(nft.ErrClassNotExists, token.ClassId) + } + + if !k.HasNFT(ctx, token.ClassId, token.Id) { + return sdkerrors.Wrap(nft.ErrNFTNotExists, token.Id) + } + checked[token.ClassId] = true + k.updateWithNoCheck(ctx, token) + } + return nil +} + +// BatchTransfer defines a method for sending a batch of nfts from one account to another account from a specific classID. +// Note: When the upper module uses this method, it needs to authenticate nft +func (k Keeper) BatchTransfer(ctx sdk.Context, + classID string, + nftIDs []string, + receiver sdk.AccAddress, +) error { + if !k.HasClass(ctx, classID) { + return sdkerrors.Wrap(nft.ErrClassNotExists, classID) + } + for _, nftID := range nftIDs { + if !k.HasNFT(ctx, classID, nftID) { + return sdkerrors.Wrap(nft.ErrNFTNotExists, nftID) + } + if err := k.transferWithNoCheck(ctx, classID, nftID, receiver); err != nil { + return sdkerrors.Wrap(nft.ErrNFTNotExists, nftID) + } + } + return nil +} diff --git a/x/nft/keeper/nft_batch_test.go b/x/nft/keeper/nft_batch_test.go new file mode 100644 index 00000000000..d9561106e5e --- /dev/null +++ b/x/nft/keeper/nft_batch_test.go @@ -0,0 +1,363 @@ +package keeper_test + +import ( + "fmt" + "math/rand" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/nft" +) + +func (s *TestSuite) TestBatchMint() { + receiver := s.addrs[0] + testCases := []struct { + msg string + malleate func([]nft.NFT) + tokens []nft.NFT + expPass bool + }{ + { + "success with empty nft", + func(tokens []nft.NFT) { + s.saveClass(tokens) + }, + []nft.NFT{}, + true, + }, + { + "success with single nft", + func(tokens []nft.NFT) { + s.saveClass(tokens) + }, + []nft.NFT{ + {ClassId: "classID1", Id: "nftID1"}, + }, + true, + }, + { + "success with multiple nft", + func(tokens []nft.NFT) { + s.saveClass(tokens) + }, + []nft.NFT{ + {ClassId: "classID1", Id: "nftID1"}, + {ClassId: "classID1", Id: "nftID2"}, + }, + true, + }, + { + "success with multiple class and multiple nft", + func(tokens []nft.NFT) { + s.saveClass(tokens) + }, + []nft.NFT{ + {ClassId: "classID1", Id: "nftID1"}, + {ClassId: "classID1", Id: "nftID2"}, + {ClassId: "classID2", Id: "nftID1"}, + {ClassId: "classID2", Id: "nftID2"}, + }, + true, + }, + { + "faild with repeated nft", + func(tokens []nft.NFT) { + s.saveClass(tokens) + }, + []nft.NFT{ + {ClassId: "classID1", Id: "nftID1"}, + {ClassId: "classID1", Id: "nftID1"}, + {ClassId: "classID2", Id: "nftID2"}, + }, + false, + }, + { + "faild with not exist class", + func(tokens []nft.NFT) { + //do nothing + }, + []nft.NFT{ + {ClassId: "classID1", Id: "nftID1"}, + {ClassId: "classID1", Id: "nftID1"}, + {ClassId: "classID2", Id: "nftID2"}, + }, + false, + }, + { + "faild with exist nft", + func(tokens []nft.NFT) { + s.saveClass(tokens) + idx := rand.Intn(len(tokens)) + s.nftKeeper.Mint(s.ctx, tokens[idx], receiver) + }, + []nft.NFT{ + {ClassId: "classID1", Id: "nftID1"}, + {ClassId: "classID1", Id: "nftID2"}, + {ClassId: "classID2", Id: "nftID2"}, + }, + false, + }, + } + + for _, tc := range testCases { + s.Run(fmt.Sprintf("Case %s", tc.msg), func() { + s.SetupTest() // reset + tc.malleate(tc.tokens) + + err := s.nftKeeper.BatchMint(s.ctx, tc.tokens, receiver) + if tc.expPass { + s.Require().NoError(err) + + classMap := groupByClassID(tc.tokens) + for classID, tokens := range classMap { + for _, token := range tokens { + actNFT, has := s.nftKeeper.GetNFT(s.ctx, token.ClassId, token.Id) + s.Require().True(has) + s.Require().EqualValues(token, actNFT) + + owner := s.nftKeeper.GetOwner(s.ctx, token.ClassId, token.Id) + s.Require().True(receiver.Equals(owner)) + } + + actNFTs := s.nftKeeper.GetNFTsOfClass(s.ctx, classID) + s.Require().EqualValues(tokens, actNFTs) + + actNFTs = s.nftKeeper.GetNFTsOfClassByOwner(s.ctx, classID, receiver) + s.Require().EqualValues(tokens, actNFTs) + + balance := s.nftKeeper.GetBalance(s.ctx, classID, receiver) + s.Require().EqualValues(len(tokens), balance) + + supply := s.nftKeeper.GetTotalSupply(s.ctx, classID) + s.Require().EqualValues(len(tokens), supply) + } + return + } + s.Require().Error(err) + }) + } +} + +func (s *TestSuite) TestBatchBurn() { + receiver := s.addrs[0] + tokens := []nft.NFT{ + {ClassId: "classID1", Id: "nftID1"}, + {ClassId: "classID1", Id: "nftID2"}, + {ClassId: "classID2", Id: "nftID1"}, + {ClassId: "classID2", Id: "nftID2"}, + } + + testCases := []struct { + msg string + malleate func() + classID string + nftIDs []string + expPass bool + }{ + { + "success", + func() { + s.saveClass(tokens) + s.nftKeeper.BatchMint(s.ctx, tokens, receiver) + }, + "classID1", + []string{"nftID1", "nftID2"}, + true, + }, + { + "failed with not exist classID", + func() {}, + "classID1", + []string{"nftID1", "nftID2"}, + false, + }, + { + "failed with not exist nftID", + func() { + s.saveClass(tokens) + }, + "classID1", + []string{"nftID1", "nftID2"}, + false, + }, + } + + for _, tc := range testCases { + s.Run(fmt.Sprintf("Case %s", tc.msg), func() { + s.SetupTest() // reset + tc.malleate() + + err := s.nftKeeper.BatchBurn(s.ctx, tc.classID, tc.nftIDs) + if tc.expPass { + s.Require().NoError(err) + for _, nftID := range tc.nftIDs { + s.Require().False(s.nftKeeper.HasNFT(s.ctx, tc.classID, nftID)) + } + return + } + s.Require().Error(err) + }) + } + +} + +func (s *TestSuite) TestBatchUpdate() { + receiver := s.addrs[0] + tokens := []nft.NFT{ + {ClassId: "classID1", Id: "nftID1"}, + {ClassId: "classID1", Id: "nftID2"}, + {ClassId: "classID2", Id: "nftID1"}, + {ClassId: "classID2", Id: "nftID2"}, + } + testCases := []struct { + msg string + malleate func() + tokens []nft.NFT + expPass bool + }{ + { + "success", + func() { + s.saveClass(tokens) + s.nftKeeper.BatchMint(s.ctx, tokens, receiver) + }, + []nft.NFT{ + {ClassId: "classID1", Id: "nftID1", Uri: "nftID1_URI"}, + {ClassId: "classID2", Id: "nftID2", Uri: "nftID2_URI"}, + }, + true, + }, + { + "failed with not exist classID", + func() {}, + []nft.NFT{ + {ClassId: "classID1", Id: "nftID1", Uri: "nftID1_URI"}, + {ClassId: "classID2", Id: "nftID2", Uri: "nftID2_URI"}, + }, + false, + }, + { + "failed with not exist nftID", + func() { + s.saveClass(tokens) + }, + []nft.NFT{ + {ClassId: "classID1", Id: "nftID1", Uri: "nftID1_URI"}, + {ClassId: "classID2", Id: "nftID2", Uri: "nftID2_URI"}, + }, + false, + }, + } + + for _, tc := range testCases { + s.Run(fmt.Sprintf("Case %s", tc.msg), func() { + s.SetupTest() // reset + tc.malleate() + + err := s.nftKeeper.BatchUpdate(s.ctx, tc.tokens) + if tc.expPass { + s.Require().NoError(err) + for _, token := range tc.tokens { + actToken, found := s.nftKeeper.GetNFT(s.ctx, token.ClassId, token.Id) + s.Require().True(found) + s.Require().EqualValues(token, actToken) + } + return + } + s.Require().Error(err) + }) + } + +} + +func (s *TestSuite) TestBatchTransfer() { + owner := s.addrs[0] + receiver := s.addrs[1] + tokens := []nft.NFT{ + {ClassId: "classID1", Id: "nftID1"}, + {ClassId: "classID1", Id: "nftID2"}, + {ClassId: "classID2", Id: "nftID1"}, + {ClassId: "classID2", Id: "nftID2"}, + } + testCases := []struct { + msg string + malleate func() + classID string + nftIDs []string + expPass bool + }{ + { + "success", + func() { + s.saveClass(tokens) + s.nftKeeper.BatchMint(s.ctx, tokens, owner) + }, + "classID1", + []string{"nftID1", "nftID2"}, + true, + }, + { + "failed with not exist classID", + func() { + s.saveClass(tokens) + s.nftKeeper.BatchMint(s.ctx, tokens, receiver) + }, + "classID3", + []string{"nftID1", "nftID2"}, + false, + }, + { + "failed with not exist nftID", + func() { + s.saveClass(tokens) + }, + "classID1", + []string{"nftID1", "nftID2"}, + false, + }, + } + + for _, tc := range testCases { + s.Run(fmt.Sprintf("Case %s", tc.msg), func() { + s.SetupTest() // reset + tc.malleate() + + err := s.nftKeeper.BatchTransfer(s.ctx, tc.classID, tc.nftIDs, receiver) + if tc.expPass { + s.Require().NoError(err) + for _, nftID := range tc.nftIDs { + actOwner := s.nftKeeper.GetOwner(s.ctx, tc.classID, nftID) + s.Require().EqualValues(receiver, actOwner) + } + return + } + s.Require().Error(err) + }) + } + +} + +func groupByClassID(tokens []nft.NFT) map[string][]nft.NFT { + classMap := make(map[string][]nft.NFT, len(tokens)) + for _, token := range tokens { + if _, ok := classMap[token.ClassId]; !ok { + classMap[token.ClassId] = make([]nft.NFT, 0) + } + classMap[token.ClassId] = append(classMap[token.ClassId], token) + } + return classMap +} + +func (s *TestSuite) saveClass(tokens []nft.NFT) { + classMap := groupByClassID(tokens) + for classID := range classMap { + err := s.nftKeeper.SaveClass(s.ctx, nft.Class{Id: classID}) + s.Require().NoError(err) + } +} + +func (s *TestSuite) mintNFT(tokens []nft.NFT, receiver sdk.AccAddress) { + for _, token := range tokens { + err := s.nftKeeper.Mint(s.ctx, token, receiver) + s.Require().NoError(err) + } +}