diff --git a/.circleci/config.yml b/.circleci/config.yml index 557ed7d13dd7..43b8dcfa7924 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -87,6 +87,13 @@ jobs: - make: target: test_sim_import_export description: "Test application import/export simulation" + - run: + command: | + mkdir -p /tmp/errors + cp /tmp/**/app-simulation-seed* /tmp/errors/ + when: on_fail + - store_artifacts: + path: /tmp/errors test_sim_after_import: executor: golang @@ -94,6 +101,13 @@ jobs: - make: target: test_sim_after_import description: "Test simulation after import" + - run: + command: | + mkdir -p /tmp/errors + cp /tmp/**/app-simulation-seed* /tmp/errors/ + when: on_fail + - store_artifacts: + path: /tmp/errors test_sim_multi_seed_long: executor: golang @@ -101,6 +115,13 @@ jobs: - make: target: test_sim_multi_seed_long description: "Test multi-seed simulation (long)" + - run: + command: | + mkdir -p /tmp/errors + cp /tmp/**/app-simulation-seed* /tmp/errors/ + when: on_fail + - store_artifacts: + path: /tmp/errors test_sim_multi_seed_short: executor: golang @@ -108,6 +129,14 @@ jobs: - make: target: test_sim_multi_seed_short description: "Test multi-seed simulation (short)" + - run: + command: | + mkdir -p /tmp/errors + cp /tmp/**/app-simulation-seed* /tmp/errors/ + when: on_fail + + - store_artifacts: + path: /tmp/errors test_cover: executor: golang diff --git a/.codecov.yml b/.codecov.yml index 7321557bc15f..a751662cd1a1 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -24,3 +24,4 @@ ignore: - "docs" - "*.md" - "*.rst" + - "x/**/test_common.go" diff --git a/crypto/keys/output_test.go b/crypto/keys/output_test.go index 1bc0f403f50e..bec0e8b5bff3 100644 --- a/crypto/keys/output_test.go +++ b/crypto/keys/output_test.go @@ -3,11 +3,12 @@ package keys import ( "testing" - sdk "github.com/cosmos/cosmos-sdk/types" "github.com/stretchr/testify/require" "github.com/tendermint/tendermint/crypto" "github.com/tendermint/tendermint/crypto/multisig" "github.com/tendermint/tendermint/crypto/secp256k1" + + sdk "github.com/cosmos/cosmos-sdk/types" ) func TestBech32KeysOutput(t *testing.T) { diff --git a/docs/spec/README.md b/docs/spec/README.md index aef36b928214..5c2a3f6063ed 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -23,10 +23,7 @@ block. - [Mint](./mint) - Staking token provision creation. - [Params](./params) - Globally available parameter store. - [Supply](./supply) - Total supply of the chain. - -## Interchain standards - -- [ICS30](./_ics/ics-030-signed-messages.md) - Signed messages standard. +- [NFT](./nft) - Non-fungible tokens. For details on the underlying blockchain and p2p protocols, see the [Tendermint specification](https://github.com/tendermint/tendermint/tree/master/docs/spec). diff --git a/docs/spec/SPEC-SPEC.md b/docs/spec/SPEC-SPEC.md index d600c24f7010..fb95c3ab96ef 100644 --- a/docs/spec/SPEC-SPEC.md +++ b/docs/spec/SPEC-SPEC.md @@ -1,7 +1,7 @@ # Specification of Specifications This file intends to outline the common structure for specifications within -this directory. +this directory. ## Tense @@ -15,7 +15,7 @@ be considered preferable. In certain instances, due to the complex nature of the functionality being described pseudo-code may the most suitable form of specification. In these cases use of pseudo-code is permissible, but should be presented in a concise manner, ideally restricted to only the complex -element as a part of a larger description. +element as a part of a larger description. ## Common Layout @@ -23,20 +23,20 @@ The following generalized file structure should be used to breakdown specifications for modules. With the exception of README.md, `XX` at the beginning of the file name should be replaced with a number to indicate document flow (ex. read `01_state.md` before `02_state_transitions.md`). The -following list is nonbinding and all files are optional. - - - `README.md` - overview of the module - - `XX_concepts.md` - describe specialized concepts and definitions used throughout the spec - - `XX_state.md` - specify and describe structures expected to marshalled into the store, and their keys - - `XX_state_transitions.md` - standard state transition operations triggered by hooks, messages, etc. - - `XX_messages.md` - specify message structure(s) and expected state machine behaviour(s) - - `XX_begin_block.md` - specify any begin-block operations - - `XX_end_block.md` - specify any end-block operations - - `XX_hooks.md` - describe available hooks to be called by/from this module - - `XX_tags.md` - list and describe event tags used - - `XX_params.md` - list all module parameters, their types (in JSON) and examples - - `XX_future_improvements.md` - describe future improvements of this module - - `XX_appendix.md` - supplementary details referenced elsewhere within the spec +following list is nonbinding and all files are optional. + +- `README.md` - overview of the module +- `XX_concepts.md` - describe specialized concepts and definitions used throughout the spec +- `XX_state.md` - specify and describe structures expected to marshalled into the store, and their keys +- `XX_state_transitions.md` - standard state transition operations triggered by hooks, messages, etc. +- `XX_messages.md` - specify message structure(s) and expected state machine behaviour(s) +- `XX_begin_block.md` - specify any begin-block operations +- `XX_end_block.md` - specify any end-block operations +- `XX_hooks.md` - describe available hooks to be called by/from this module +- `XX_events.md` - list and describe event tags used +- `XX_params.md` - list all module parameters, their types (in JSON) and examples +- `XX_future_improvements.md` - describe future improvements of this module +- `XX_appendix.md` - supplementary details referenced elsewhere within the spec ### Notation for key-value mapping @@ -44,14 +44,14 @@ Within `state.md` the following notation `->` should be used to describe key to value mapping: ``` -key -> value +key -> value ``` to represent byte concatenation the `|` may be used. In addition, encoding type may be specified, for example: ``` -0x00 | addressBytes | address2Bytes -> amino(value_object) +0x00 | addressBytes | address2Bytes -> amino(value_object) ``` Additionally, index mappings may be specified by mapping to the `nil` value, for example: diff --git a/docs/spec/nft/01_concepts.md b/docs/spec/nft/01_concepts.md new file mode 100644 index 000000000000..22e27149ccb8 --- /dev/null +++ b/docs/spec/nft/01_concepts.md @@ -0,0 +1,50 @@ +# Concepts + +## NFT + +The `NFT` Interface inherits the BaseNFT struct and includes getter functions for the asset data. It also includes a Stringer function in order to print the struct. The interface may change if metadata is moved to it’s own module as it might no longer be necessary for the flexibility of an interface. + +```go +// NFT non fungible token interface +type NFT interface { + GetID() string // unique identifier of the NFT + GetOwner() sdk.AccAddress // gets owner account of the NFT + SetOwner(address sdk.AccAddress) // gets owner account of the NFT + GetTokenURI() string // metadata field: URI to retrieve the of chain metadata of the NFT + EditMetadata(tokenURI string) // edit metadata of the NFT + String() string // string representation of the NFT object +} +``` + +## Collections + +A Collection is used to organized sets of NFTs. It contains the denomination of the NFT instead of storing it within each NFT. This saves storage space by removing redundancy. + +```go +// Collection of non fungible tokens +type Collection struct { + Denom string `json:"denom,omitempty"` // name of the collection; not exported to clients + NFTs []*NFT `json:"nfts"` // NFTs that belongs to a collection +} +``` + +## Owner + +An Owner is a struct that includes information about all NFTs owned by a single account. It would be possible to retrieve this information by looping through all Collections but that process could become computationally prohibitive so a more efficient retrieval system is to store redundant information limited to the token ID by owner. + +```go +// Owner of non fungible tokens +type Owner struct { + Address sdk.AccAddress `json:"address"` + IDCollections IDCollections `json:"IDCollections"` +} +``` + +An `IDCollection` is similar to a `Collection` except instead of containing NFTs it only contains an array of `NFT` IDs. This saves storage by avoiding redundancy. + +```go +// IDCollection of non fungible tokens +type IDCollection struct { + Denom string `json:"denom"` + IDs []string `json:"IDs"` +} diff --git a/docs/spec/nft/02_state.md b/docs/spec/nft/02_state.md new file mode 100644 index 000000000000..19019325c5e4 --- /dev/null +++ b/docs/spec/nft/02_state.md @@ -0,0 +1,20 @@ +# State + +## Collections + +As all NFTs belong to a specific `Collection`, they are kept on store in an array +within each `Collection`. Every time an NFT that belongs to a collection is updated, +it needs to be updated on the corresponding NFT array on the corresponding `Collection`. +`denomHash` is used as part of the key to limit the length of the `denomBytes` which is + a hash of `denomBytes` made from the tendermint [tmhash library](https://github.com/tendermint/tendermint/tree/master/crypto/tmhash). + +- Collections: `0x00 | denomHash -> amino(Collection)` +- denomHash: `tmhash(denomBytes)` + +## Owners + +The ownership of an NFT is set initially when an NFT is minted and needs to be +updated every time there's a transfer or when an NFT is burned. + +- Owners: `0x01 | addressBytes | denomHash -> amino(Owner)` +- denomHash: `tmhash(denomBytes)` diff --git a/docs/spec/nft/03_messages.md b/docs/spec/nft/03_messages.md new file mode 100644 index 000000000000..b0963c9e7088 --- /dev/null +++ b/docs/spec/nft/03_messages.md @@ -0,0 +1,86 @@ +# Messages + +## MsgTransferNFT + +This is the most commonly expected MsgType to be supported across chains. While each application specific blockchain will have very different adoption of the `MsgMintNFT`, `MsgBurnNFT` and `MsgEditNFTMetadata` it should be expected that most chains support the ability to transfer ownership of the NFT asset. The exception to this would be non-transferable NFTs that might be attached to reputation or some asset which should not be transferable. It still makes sense for this to be represented as an NFT because there are common queriers which will remain relevant to the NFT type even if non-transferable. This Message will fail if the NFT does not exist. By default it will not fail if the transfer is executed by someone beside the owner. **It is highly recommended that a custom handler is made to restrict use of this Message type to prevent unintended use.** + +| **Field** | **Type** | **Description** | +|:----------|:-----------------|:--------------------------------------------------------------------------------------------------------------| +| Sender | `sdk.AccAddress` | The account address of the user sending the NFT. By default it is __not__ required that the sender is also the owner of the NFT. | +| Recipient | `sdk.AccAddress` | The account address who will receive the NFT as a result of the transfer transaction. | +| Denom | `string` | The denomination of the NFT, necessary as multiple denominations are able to be represented on each chain. | +| ID | `string` | The unique ID of the NFT being transferred | + +```go +// MsgTransferNFT defines a TransferNFT message +type MsgTransferNFT struct { + Sender sdk.AccAddress + Recipient sdk.AccAddress + Denom string + ID string +} +``` + +## MsgEditNFTMetadata + +This message type allows the `TokenURI` to be updated. By default anyone can execute this Message type. **It is highly recommended that a custom handler is made to restrict use of this Message type to prevent unintended use.** + +| **Field** | **Type** | **Description** | +|:------------|:-----------------|:-----------------------------------------------------------------------------------------------------------| +| Sender | `sdk.AccAddress` | The creator of the message | +| ID | `string` | The unique ID of the NFT being edited | +| Denom | `string` | The denomination of the NFT, necessary as multiple denominations are able to be represented on each chain. | +| TokenURI | `string` | The URI pointing to a JSON object that contains subsequent metadata information off-chain | + +```go +// MsgEditNFTMetadata edits an NFT's metadata +type MsgEditNFTMetadata struct { + Sender sdk.AccAddress + ID string + Denom string + TokenURI string +} +``` + +## MsgMintNFT + +This message type is used for minting new tokens. If a new `NFT` is minted under a new `Denom`, a new `Collection` will also be created, otherwise the `NFT` is added to the existing `Collection`. If a new `NFT` is minted by a new account, a new `Owner` is created, otherwise the `NFT` `ID` is added to the existing `Owner`'s `IDCollection`. By default anyone can execute this Message type. **It is highly recommended that a custom handler is made to restrict use of this Message type to prevent unintended use.** + +| **Field** | **Type** | **Description** | +|:------------|:-----------------|:-----------------------------------------------------------------------------------------| +| Sender | `sdk.AccAddress` | The sender of the Message | +| Recipient | `sdk.AccAddress` | The recipiet of the new NFT | +| ID | `string` | The unique ID of the NFT being minted | +| Denom | `string` | The denomination of the NFT. | +| TokenURI | `string` | The URI pointing to a JSON object that contains subsequent metadata information off-chain | + +```go +// MsgMintNFT defines a MintNFT message +type MsgMintNFT struct { + Sender sdk.AccAddress + Recipient sdk.AccAddress + ID string + Denom string + TokenURI string +} +``` + +### MsgBurnNFT + +This message type is used for burning tokens which destroys and deletes them. By default anyone can execute this Message type. **It is highly recommended that a custom handler is made to restrict use of this Message type to prevent unintended use.** + + +| **Field** | **Type** | **Description** | +|:----------|:-----------------|:---------------------------------------------------| +| Sender | `sdk.AccAddress` | The account address of the user burning the token. | +| ID | `string` | The ID of the Token. | +| Denom | `string` | The Denom of the Token. | + +```go +// MsgBurnNFT defines a BurnNFT message +type MsgBurnNFT struct { + Sender sdk.AccAddress + ID string + Denom string +} +``` diff --git a/docs/spec/nft/04_events.md b/docs/spec/nft/04_events.md new file mode 100644 index 000000000000..5d0c204217dd --- /dev/null +++ b/docs/spec/nft/04_events.md @@ -0,0 +1,48 @@ +# Events + +The nft module emits the following events: + +## Handlers + +### MsgTransferNFT + +| Type | Attribute Key | Attribute Value | +|--------------|---------------|--------------------| +| transfer_nft | denom | {nftDenom} | +| transfer_nft | nft-id | {nftID} | +| transfer_nft | recipient | {recipientAddress} | +| message | module | nft | +| message | action | transfer_nft | +| message | sender | {senderAddress} | + +### MsgEditNFTMetadata + +| Type | Attribute Key | Attribute Value | +|-------------------|---------------|-------------------| +| edit_nft_metadata | denom | {nftDenom} | +| edit_nft_metadata | nft-id | {nftID} | +| message | module | nft | +| message | action | edit_nft_metadata | +| message | sender | {senderAddress} | +| message | token-uri | {tokenURI} | + +### MsgMintNFT + +| Type | Attribute Key | Attribute Value | +|----------|---------------|-----------------| +| mint_nft | denom | {nftDenom} | +| mint_nft | nft-id | {nftID} | +| message | module | nft | +| message | action | mint_nft | +| message | sender | {senderAddress} | +| message | token-uri | {tokenURI} | + +### MsgBurnNFTs + +| Type | Attribute Key | Attribute Value | +|----------|---------------|-----------------| +| burn_nft | denom | {nftDenom} | +| burn_nft | nft-id | {nftID} | +| message | module | nft | +| message | action | burn_nft | +| message | sender | {senderAddress} | diff --git a/docs/spec/nft/05_future_improvements.md b/docs/spec/nft/05_future_improvements.md new file mode 100644 index 000000000000..303dd22fdaf3 --- /dev/null +++ b/docs/spec/nft/05_future_improvements.md @@ -0,0 +1,5 @@ +# Future Improvements + +There's interesting work that could be done about moving metadata into its own module. This could act as one of the `tokenURI` endpoints if a chain chooses to offer storage as a solution. Furthermore on-chain metadata can be trusted to a higher degree and might be used in secondary actions like price evaluation. Moving metadata to it's own module could be useful for the Bank Module as well. It would be able to describe attributes like decimal places and information regarding vesting schedules. It would be needed to have a level of introspection to describe the content without actually delivering the content for client libraries to interact with it. Using schema.org as a common location to settle metadata schema structure would be a good and impartial place to do so. + +Inter-Blockchain Communication will need to develop its own Message types that allow NFTs to be transferred across chains. Making sure that spec is able to support the NFTs created by this module should be easy. What might be more complicated is a transfer that includes optional metadata so that a receiving chain has the option of parsing and storing it instead of making IBC queries when that data needs to be accessed (assuming that information stays up to date). diff --git a/docs/spec/nft/06_appendix.md b/docs/spec/nft/06_appendix.md new file mode 100644 index 000000000000..c554e0f259ed --- /dev/null +++ b/docs/spec/nft/06_appendix.md @@ -0,0 +1,7 @@ +# Appendix + +* Cosmos SDK: [PR #4209](https://github.com/cosmos/cosmos-sdk/pull/4209) +* Cosmos SDK: [Issue #4046](https://github.com/cosmos/cosmos-sdk/issues/4046) +* Interchain Standards: [ICS #17](https://github.com/cosmos/ics/issues/30) +* Binance: [BEP #7](https://github.com/binance-chain/BEPs/pull/7) +* Ethereum: [EIP #721](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-721.md) diff --git a/docs/spec/nft/README.md b/docs/spec/nft/README.md new file mode 100644 index 000000000000..8635568225db --- /dev/null +++ b/docs/spec/nft/README.md @@ -0,0 +1,99 @@ +# NFT Specification + +## Overview + +The NFT Module described here is meant to be used as a module across chains for managing non-fungible token that represent individual assets with unique features. This standard was first developed on Ethereum within the ERC-721 and the subsequent EIP of the same name. This standard utilized the features of the Ethereum blockchain as well as the restrictions. The subsequent ERC-1155 standard addressed some of the restrictions of Ethereum regarding storage costs and semi-fungible assets. + +NFTs on application specific blockchains share some but not all features as their Ethereum brethren. Since application specific blockchains are more flexible in how their resources are utilized it makes sense that should have the option of exploiting those resources. This includes the aility to use strings as IDs and to optionally store metadata on chain. The user-flow of composability with smart contracts should also be rethought on application specific blockchains with regard to Inter-Blockchain Communication as it is a different design experience from communication between smart contracts. + +## Contents + +1. **[Concepts](./01_concepts.md)** + - [NFT](./01_concepts.md#nft) + - [Collections](./01_concepts.md#collections) +2. **[State](./02_state.md)** + - [Collections](./02_state.md#collections) + - [Owners](./02_state.md#owners) +3. **[Messages](./03_messages.md)** + - [Transfer NFT](./03_messages.md#transfer-nft) + - [Edit Metadata](./03_messages.md#edit-metadata) + - [Mint NFT](./03_messages.md#mint-nft) + - [Burn NFT](./03_messages.md#burn-nft) +4. **[Events](./04_events.md)** +5. **[Future Improvements](./05_future_improvements.md)** + +## A Note on Metadata & IBC + +The BaseNFT includes `tokenURI` in order to be backwards compatible with Ethereum based NFTs. However the `NFT` type is an interface that allows arbitrary metadata to be stored on chain should it need be. Originally the module included `name`, `description` and `image` to demonstrate these capabilities. They were removed in order for the NFT to be more efficient for use cases that don't include a need for that information to be stored on chain. A demonstration of including them will be included in a sample app. It is also under discussion to move all metadata to a separate module that can handle arbitrary amounts of data on chain and can be used to describe assets beyond Non-Fungible Tokens, like normal Fungible Tokens `Coin` that could describe attributes like decimal places and vesting status. + +A stand-alone metadata Module would allow for independent standards to evolve regarding arbitrary asset types with expanding precision. The standards supported by [http://schema.org](http://schema.org) and the process of adding nested information is being considered as a starting point for that standard. The Blockchain Gaming Alliance is working on a metadata standard to be used for specifically blockchain gaming assets. + +With regards to Inter-Blockchain Communication the responsibility of the integrity of the metadata should be left to the origin chain. If a secondary chain was responsible for storing the source of truth of the metadata for an asset tracking that source of truth would become difficult if not impossible to track. Since origin chains are where the design and use of the NFT is determined, it should be up to that origin chain to decide who can update metadata and under what circumstances. Secondary chains can use IBC queriers to check needed metadata or keep redundant copies of the metadata locally when they receive the NFT originally. In that case it should be up to te secondary chain to keep the metadata in sync if need be, similar to how layer 2 solutions keep metadata in sync with a source of truth using events. + +## Custom App-Specific Handlers + +Each message type comes with a default handler that can be used by default but will most likely be too limited for each use case. In order to make them useful for as many situations as possible, there are very few limitations on who can execute the Messages and do things like mint, burn or edit metadata. We recommend that custom handlers are created to add in custom logic and restrictions over when the Message types can be executed. Below is an example implementation for initializing the module within the module manager so that a custom handler can be added. This can be seen in the example [NFT app](https://github.com/okwme/cosmos-nft). + +```go +// custom-handler.go + +// OverrideNFTModule overrides the NFT module for custom handlers +type OverrideNFTModule struct { + nft.AppModule + k nft.Keeper +} + +// NewHandler overwrites the legacy NewHandler in order to allow custom logic for handling the messages +func (am OverrideNFTModule) NewHandler() sdk.Handler { + return CustomNFTHandler(am.k) +} + +// NewOverrideNFTModule generates a new NFT Module +func NewOverrideNFTModule(appModule nft.AppModule, keeper nft.Keeper) OverrideNFTModule { + return OverrideNFTModule{ + AppModule: appModule, + k: keeper, + } +} +``` + +You can see here that `OverrideNFTModule` is the same as `nft.AppModule` except for the `NewHandler()` method. This method now returns a new Handler called `CustomNFTHandler`. This custom handler can be seen below: + +```go +// CustomNFTHandler routes the messages to the handlers +func CustomNFTHandler(k keeper.Keeper) sdk.Handler { + return func(ctx sdk.Context, msg sdk.Msg) sdk.Result { + switch msg := msg.(type) { + case types.MsgTransferNFT: + return nft.HandleMsgTransferNFT(ctx, msg, k) + case types.MsgEditNFTMetadata: + return nft.HandleMsgEditNFTMetadata(ctx, msg, k) + case types.MsgMintNFT: + return HandleMsgMintNFTCustom(ctx, msg, k) // <-- This one is custom, the others fall back onto the default + case types.MsgBurnNFT: + return nft.HandleMsgBurnNFT(ctx, msg, k) + default: + errMsg := fmt.Sprintf("unrecognized nft message type: %T", msg) + return sdk.ErrUnknownRequest(errMsg).Result() + } + } +} + +// HandleMsgMintNFTCustom is a custom handler that handles MsgMintNFT +func HandleMsgMintNFTCustom(ctx sdk.Context, msg types.MsgMintNFT, k keeper.Keeper, +) sdk.Result { + + isTwilight := checkTwilight(ctx) + + if isTwilight { + return nft.HandleMsgMintNFT(ctx, msg, k) + } + + errMsg := fmt.Sprintf("Can't mint astral bodies outside of twilight!") + return sdk.ErrUnknownRequest(errMsg).Result() + } +``` + +The default handlers are imported here with the NFT module and used for `MsgTransferNFT`, `MsgEditNFTMetadata` and `MsgBurnNFT`. The `MsgMintNFT` however is handled with a custom function called `HandleMsgMintNFTCustom`. This custom function also utilizes the imported NFT module handler `HandleMsgMintNFT`, but only after certain conditions are checked. In this case it checks a function called `checkTwilight` which returns a boolean. Only if `isTwilight` is true will the Message succeed. + +This pattern of inheriting and utilizing the module handlers wrapped in custom logic should allow each application specific blockchain to use the NFT while customizing it to their specific requirements. diff --git a/go.mod b/go.mod index 90d905cfebde..a010df7e1e7b 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/btcsuite/btcd v0.0.0-20190115013929-ed77733ec07d github.com/cosmos/go-bip39 v0.0.0-20180618194314-52158e4697b8 github.com/cosmos/ledger-cosmos-go v0.10.3 + github.com/cosmos/tools v0.0.0-20190729191304-444fa9c55188 // indirect github.com/fortytw2/leaktest v1.3.0 // indirect github.com/gogo/protobuf v1.2.1 github.com/golang/mock v1.3.1-0.20190508161146-9fa652df1129 diff --git a/go.sum b/go.sum index d4983bef4464..b4ea88ed3d25 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,10 @@ github.com/cosmos/ledger-cosmos-go v0.10.3 h1:Qhi5yTR5Pg1CaTpd00pxlGwNl4sFRdtK1J github.com/cosmos/ledger-cosmos-go v0.10.3/go.mod h1:J8//BsAGTo3OC/vDLjMRFLW6q0WAaXvHnVc7ZmE8iUY= github.com/cosmos/ledger-go v0.9.2 h1:Nnao/dLwaVTk1Q5U9THldpUMMXU94BOTWPddSmVB6pI= github.com/cosmos/ledger-go v0.9.2/go.mod h1:oZJ2hHAZROdlHiwTg4t7kP+GKIIkBT+o6c9QWFanOyI= +github.com/cosmos/tools v0.0.0-20190729191304-444fa9c55188 h1:KZsNQXLq7ZUURaBjVrztEqZX+d7qpjQkS4a0jbCMHIY= +github.com/cosmos/tools v0.0.0-20190729191304-444fa9c55188/go.mod h1:ycjJZ351OX/Y/DYgZqNn1WLCgpmVH7j29THN8vjbb9U= +github.com/cosmos/tools/cmd/clog v0.0.0-20190722180430-ea942c183cba h1:YhVnGzBkE2TvfBW5fAYBdNVK/3bwTPYVbMaOIGRHFRY= +github.com/cosmos/tools/cmd/clog v0.0.0-20190722180430-ea942c183cba/go.mod h1:TdPuAVaU2rc6K24ejr/AnGznt9Fd2qjtMoRrTO4uFrI= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/simapp/app.go b/simapp/app.go index 26bac8f52395..9eb594a9dd2d 100644 --- a/simapp/app.go +++ b/simapp/app.go @@ -22,6 +22,7 @@ import ( "github.com/cosmos/cosmos-sdk/x/genutil" "github.com/cosmos/cosmos-sdk/x/gov" "github.com/cosmos/cosmos-sdk/x/mint" + "github.com/cosmos/cosmos-sdk/x/nft" "github.com/cosmos/cosmos-sdk/x/params" paramsclient "github.com/cosmos/cosmos-sdk/x/params/client" "github.com/cosmos/cosmos-sdk/x/slashing" @@ -53,6 +54,7 @@ var ( params.AppModuleBasic{}, crisis.AppModuleBasic{}, slashing.AppModuleBasic{}, + nft.AppModuleBasic{}, supply.AppModuleBasic{}, ) @@ -100,6 +102,7 @@ type SimApp struct { GovKeeper gov.Keeper CrisisKeeper crisis.Keeper ParamsKeeper params.Keeper + NFTKeeper nft.Keeper // the module manager mm *module.Manager @@ -122,7 +125,7 @@ func NewSimApp( keys := sdk.NewKVStoreKeys(bam.MainStoreKey, auth.StoreKey, staking.StoreKey, supply.StoreKey, mint.StoreKey, distr.StoreKey, slashing.StoreKey, - gov.StoreKey, params.StoreKey) + gov.StoreKey, params.StoreKey, nft.StoreKey) tkeys := sdk.NewTransientStoreKeys(staking.TStoreKey, params.TStoreKey) app := &SimApp{ @@ -156,6 +159,7 @@ func NewSimApp( app.SlashingKeeper = slashing.NewKeeper(app.cdc, keys[slashing.StoreKey], &stakingKeeper, slashingSubspace, slashing.DefaultCodespace) app.CrisisKeeper = crisis.NewKeeper(crisisSubspace, invCheckPeriod, app.SupplyKeeper, auth.FeeCollectorName) + app.NFTKeeper = nft.NewKeeper(app.cdc, keys[nft.StoreKey]) // register the proposal types govRouter := gov.NewRouter() @@ -185,6 +189,7 @@ func NewSimApp( mint.NewAppModule(app.MintKeeper), slashing.NewAppModule(app.SlashingKeeper, app.StakingKeeper), staking.NewAppModule(app.StakingKeeper, app.AccountKeeper, app.SupplyKeeper), + nft.NewAppModule(app.NFTKeeper), ) // During begin block slashing happens after distr.BeginBlocker so that diff --git a/simapp/params.go b/simapp/params.go index b081715f3773..e004469a7e0a 100644 --- a/simapp/params.go +++ b/simapp/params.go @@ -20,4 +20,8 @@ const ( OpWeightMsgUndelegate = "op_weight_msg_undelegate" OpWeightMsgBeginRedelegate = "op_weight_msg_begin_redelegate" OpWeightMsgUnjail = "op_weight_msg_unjail" + OpWeightMsgTransferNFT = "op_weight_msg_transfer_nft" + OpWeightMsgEditNFTMetadata = "op_weight_msg_edit_nft_metadata" + OpWeightMsgMintNFT = "op_weight_msg_mint_nft" + OpWeightMsgBurnNFT = "op_weight_msg_burn_nft" ) diff --git a/simapp/sim_test.go b/simapp/sim_test.go index 3557432da063..67d73931466c 100644 --- a/simapp/sim_test.go +++ b/simapp/sim_test.go @@ -24,6 +24,7 @@ import ( "github.com/cosmos/cosmos-sdk/x/gov" govsimops "github.com/cosmos/cosmos-sdk/x/gov/simulation/operations" "github.com/cosmos/cosmos-sdk/x/mint" + nftsimops "github.com/cosmos/cosmos-sdk/x/nft/simulation/operations" "github.com/cosmos/cosmos-sdk/x/params" paramsimops "github.com/cosmos/cosmos-sdk/x/params/simulation/operations" "github.com/cosmos/cosmos-sdk/x/simulation" @@ -53,6 +54,7 @@ func testAndRunTxs(app *SimApp, config simulation.Config) []simulation.WeightedO cdc.MustUnmarshalJSON(bz, &ap) } + // nolint: govet return []simulation.WeightedOperation{ { func(_ *rand.Rand) int { @@ -230,6 +232,50 @@ func testAndRunTxs(app *SimApp, config simulation.Config) []simulation.WeightedO }(nil), slashingsimops.SimulateMsgUnjail(app.SlashingKeeper), }, + { + func(_ *rand.Rand) int { + var v int + ap.GetOrGenerate(cdc, OpWeightMsgTransferNFT, &v, nil, + func(_ *rand.Rand) { + v = 100 + }) + return v + }(nil), + nftsimops.SimulateMsgTransferNFT(app.NFTKeeper), + }, + { + func(_ *rand.Rand) int { + var v int + ap.GetOrGenerate(cdc, OpWeightMsgEditNFTMetadata, &v, nil, + func(_ *rand.Rand) { + v = 100 + }) + return v + }(nil), + nftsimops.SimulateMsgEditNFTMetadata(app.NFTKeeper), + }, + { + func(_ *rand.Rand) int { + var v int + ap.GetOrGenerate(cdc, OpWeightMsgMintNFT, &v, nil, + func(_ *rand.Rand) { + v = 100 + }) + return v + }(nil), + nftsimops.SimulateMsgMintNFT(app.NFTKeeper), + }, + { + func(_ *rand.Rand) int { + var v int + ap.GetOrGenerate(cdc, OpWeightMsgBurnNFT, &v, nil, + func(_ *rand.Rand) { + v = 100 + }) + return v + }(nil), + nftsimops.SimulateMsgBurnNFT(app.NFTKeeper), + }, } } diff --git a/simapp/state.go b/simapp/state.go index c3a6c55ffa83..c531a3320215 100644 --- a/simapp/state.go +++ b/simapp/state.go @@ -13,6 +13,7 @@ import ( tmtypes "github.com/tendermint/tendermint/types" "github.com/cosmos/cosmos-sdk/x/genaccounts" + nftsim "github.com/cosmos/cosmos-sdk/x/nft/simulation" "github.com/cosmos/cosmos-sdk/x/simulation" ) @@ -95,6 +96,7 @@ func AppStateRandomizedFn( GenSupplyGenesisState(cdc, amount, numInitiallyBonded, int64(len(accs)), genesisState) GenGovGenesisState(cdc, r, appParams, genesisState) GenMintGenesisState(cdc, r, appParams, genesisState) + nftsim.GenNFTGenesisState(cdc, r, accs, appParams, genesisState) GenDistrGenesisState(cdc, r, appParams, genesisState) stakingGen := GenStakingGenesisState(cdc, r, accs, amount, numAccs, numInitiallyBonded, appParams, genesisState) GenSlashingGenesisState(cdc, r, stakingGen, appParams, genesisState) diff --git a/x/nft/alias.go b/x/nft/alias.go new file mode 100644 index 000000000000..421ac0f5fd51 --- /dev/null +++ b/x/nft/alias.go @@ -0,0 +1,106 @@ +// nolint +// autogenerated code using github.com/rigelrozanski/multitool +// aliases generated for the following subdirectories: +// ALIASGEN: github.com/cosmos/cosmos-sdk/x/nft/internal/keeper +// ALIASGEN: github.com/cosmos/cosmos-sdk/x/nft/internal/types +package nft + +import ( + "github.com/cosmos/cosmos-sdk/x/nft/internal/keeper" + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" +) + +const ( + QuerySupply = keeper.QuerySupply + QueryOwner = keeper.QueryOwner + QueryOwnerByDenom = keeper.QueryOwnerByDenom + QueryCollection = keeper.QueryCollection + QueryDenoms = keeper.QueryDenoms + QueryNFT = keeper.QueryNFT + DefaultCodespace = types.DefaultCodespace + CodeInvalidCollection = types.CodeInvalidCollection + CodeUnknownCollection = types.CodeUnknownCollection + CodeInvalidNFT = types.CodeInvalidNFT + CodeUnknownNFT = types.CodeUnknownNFT + CodeNFTAlreadyExists = types.CodeNFTAlreadyExists + CodeEmptyMetadata = types.CodeEmptyMetadata + ModuleName = types.ModuleName + StoreKey = types.StoreKey + QuerierRoute = types.QuerierRoute + RouterKey = types.RouterKey +) + +var ( + // functions aliases + RegisterInvariants = keeper.RegisterInvariants + AllInvariants = keeper.AllInvariants + SupplyInvariant = keeper.SupplyInvariant + NewKeeper = keeper.NewKeeper + NewQuerier = keeper.NewQuerier + RegisterCodec = types.RegisterCodec + NewCollection = types.NewCollection + EmptyCollection = types.EmptyCollection + NewCollections = types.NewCollections + ErrInvalidCollection = types.ErrInvalidCollection + ErrUnknownCollection = types.ErrUnknownCollection + ErrInvalidNFT = types.ErrInvalidNFT + ErrNFTAlreadyExists = types.ErrNFTAlreadyExists + ErrUnknownNFT = types.ErrUnknownNFT + ErrEmptyMetadata = types.ErrEmptyMetadata + NewGenesisState = types.NewGenesisState + DefaultGenesisState = types.DefaultGenesisState + ValidateGenesis = types.ValidateGenesis + GetCollectionKey = types.GetCollectionKey + SplitOwnerKey = types.SplitOwnerKey + GetOwnersKey = types.GetOwnersKey + GetOwnerKey = types.GetOwnerKey + NewMsgTransferNFT = types.NewMsgTransferNFT + NewMsgEditNFTMetadata = types.NewMsgEditNFTMetadata + NewMsgMintNFT = types.NewMsgMintNFT + NewMsgBurnNFT = types.NewMsgBurnNFT + NewBaseNFT = types.NewBaseNFT + NewNFTs = types.NewNFTs + NewIDCollection = types.NewIDCollection + NewOwner = types.NewOwner + NewQueryCollectionParams = types.NewQueryCollectionParams + NewQueryBalanceParams = types.NewQueryBalanceParams + NewQueryNFTParams = types.NewQueryNFTParams + + // variable aliases + ModuleCdc = types.ModuleCdc + EventTypeTransfer = types.EventTypeTransfer + EventTypeEditNFTMetadata = types.EventTypeEditNFTMetadata + EventTypeMintNFT = types.EventTypeMintNFT + EventTypeBurnNFT = types.EventTypeBurnNFT + AttributeValueCategory = types.AttributeValueCategory + AttributeKeySender = types.AttributeKeySender + AttributeKeyRecipient = types.AttributeKeyRecipient + AttributeKeyOwner = types.AttributeKeyOwner + AttributeKeyNFTID = types.AttributeKeyNFTID + AttributeKeyNFTTokenURI = types.AttributeKeyNFTTokenURI + AttributeKeyDenom = types.AttributeKeyDenom + CollectionsKeyPrefix = types.CollectionsKeyPrefix + OwnersKeyPrefix = types.OwnersKeyPrefix +) + +type ( + Keeper = keeper.Keeper + Collection = types.Collection + Collections = types.Collections + CollectionJSON = types.CollectionJSON + CodeType = types.CodeType + GenesisState = types.GenesisState + MsgTransferNFT = types.MsgTransferNFT + MsgEditNFTMetadata = types.MsgEditNFTMetadata + MsgMintNFT = types.MsgMintNFT + MsgBurnNFT = types.MsgBurnNFT + BaseNFT = types.BaseNFT + NFTs = types.NFTs + NFTJSON = types.NFTJSON + IDCollection = types.IDCollection + IDCollections = types.IDCollections + Owner = types.Owner + QueryCollectionParams = types.QueryCollectionParams + QueryBalanceParams = types.QueryBalanceParams + QueryNFTParams = types.QueryNFTParams +) diff --git a/x/nft/client/cli/query.go b/x/nft/client/cli/query.go new file mode 100644 index 000000000000..d79f88ebded0 --- /dev/null +++ b/x/nft/client/cli/query.go @@ -0,0 +1,249 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/cosmos/cosmos-sdk/client" + + "github.com/spf13/cobra" + + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/version" + "github.com/cosmos/cosmos-sdk/x/nft/exported" + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" +) + +// GetQueryCmd returns the cli query commands for this module +func GetQueryCmd(queryRoute string, cdc *codec.Codec) *cobra.Command { + nftQueryCmd := &cobra.Command{ + Use: types.ModuleName, + Short: "Querying commands for the NFT module", + } + + nftQueryCmd.AddCommand(client.GetCommands( + GetCmdQueryCollectionSupply(queryRoute, cdc), + GetCmdQueryOwner(queryRoute, cdc), + GetCmdQueryCollection(queryRoute, cdc), + GetCmdQueryDenoms(queryRoute, cdc), + GetCmdQueryNFT(queryRoute, cdc), + )...) + + return nftQueryCmd +} + +// GetCmdQueryCollectionSupply queries the supply of a nft collection +func GetCmdQueryCollectionSupply(queryRoute string, cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "supply [denom]", + Short: "total supply of a collection of NFTs", + Long: strings.TrimSpace( + fmt.Sprintf(`Get the total count of NFTs that match a certain denomination. + +Example: +$ %s query %s supply crypto-kitties +`, version.ClientName, types.ModuleName, + ), + ), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + denom := args[0] + + params := types.NewQueryCollectionParams(denom) + bz, err := cdc.MarshalJSON(params) + if err != nil { + return err + } + + res, _, err := cliCtx.QueryWithData(fmt.Sprintf("custom/%s/supply/%s", queryRoute, denom), bz) + if err != nil { + return err + } + + var out exported.NFT + err = cdc.UnmarshalJSON(res, &out) + if err != nil { + return err + } + + return cliCtx.PrintOutput(out) + }, + } +} + +// GetCmdQueryOwner queries all the NFTs owned by an account +func GetCmdQueryOwner(queryRoute string, cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "owner [accountAddress] [denom]", + Short: "get the NFTs owned by an account address", + Long: strings.TrimSpace( + fmt.Sprintf(`Get the NFTs owned by an account address optionally filtered by the denom of the NFTs. + +Example: +$ %s query %s owner cosmos1gghjut3ccd8ay0zduzj64hwre2fxs9ld75ru9p +$ %s query %s owner cosmos1gghjut3ccd8ay0zduzj64hwre2fxs9ld75ru9p cripto-kitties +`, version.ClientName, types.ModuleName, version.ClientName, types.ModuleName, + ), + ), + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + address, err := sdk.AccAddressFromBech32(args[0]) + if err != nil { + return err + } + + denom := "" + if len(args) == 2 { + denom = args[1] + } + + params := types.NewQueryBalanceParams(address, denom) + bz, err := cdc.MarshalJSON(params) + if err != nil { + return err + } + + var res []byte + if denom == "" { + res, _, err = cliCtx.QueryWithData(fmt.Sprintf("custom/%s/owner", queryRoute), bz) + } else { + res, _, err = cliCtx.QueryWithData(fmt.Sprintf("custom/%s/ownerByDenom", queryRoute), bz) + } + + if err != nil { + return err + } + + var out types.Owner + err = cdc.UnmarshalJSON(res, &out) + if err != nil { + return err + } + + return cliCtx.PrintOutput(out) + }, + } +} + +// GetCmdQueryCollection queries all the NFTs from a collection +func GetCmdQueryCollection(queryRoute string, cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "collection [denom]", + Short: "get all the NFTs from a given collection", + Long: strings.TrimSpace( + fmt.Sprintf(`Get a list of all NFTs from a given collection. + +Example: +$ %s query %s collection cripto-kitties +`, version.ClientName, types.ModuleName, + ), + ), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + denom := args[0] + + params := types.NewQueryCollectionParams(denom) + bz, err := cdc.MarshalJSON(params) + if err != nil { + return err + } + + res, _, err := cliCtx.QueryWithData(fmt.Sprintf("custom/%s/collection", queryRoute), bz) + if err != nil { + return err + } + + var out types.Collection + err = cdc.UnmarshalJSON(res, &out) + if err != nil { + return err + } + + return cliCtx.PrintOutput(out) + }, + } +} + +type stringArray []string + +func (s stringArray) String() string { return strings.Join(s[:], ",") } + +// GetCmdQueryDenoms queries all denoms +func GetCmdQueryDenoms(queryRoute string, cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "denoms", + Short: "queries all denominations of all collections of NFTs", + Long: strings.TrimSpace( + fmt.Sprintf(`Gets all denominations of all the available collections of NFTs that + are stored on the chain. + + Example: + $ %s query %s denoms + `, version.ClientName, types.ModuleName, + ), + ), + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + + res, _, err := cliCtx.QueryWithData(fmt.Sprintf("custom/%s/denoms", queryRoute), nil) + if err != nil { + return err + } + + var out stringArray + err = cdc.UnmarshalJSON(res, &out) + if err != nil { + return err + } + + return cliCtx.PrintOutput(out) + }, + } +} + +// GetCmdQueryNFT queries a single NFTs from a collection +func GetCmdQueryNFT(queryRoute string, cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "token [denom] [ID]", + Short: "query a single NFT from a collection", + Long: strings.TrimSpace( + fmt.Sprintf(`Get an NFT from a collection that has the given ID (SHA-256 hex hash). + +Example: +$ %s query %s token cripto-kitties d04b98f48e8f8bcc15c6ae5ac050801cd6dcfd428fb5f9e65c4e16e7807340fa +`, version.ClientName, types.ModuleName, + ), + ), + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + denom := args[0] + id := args[1] + + params := types.NewQueryNFTParams(denom, id) + bz, err := cdc.MarshalJSON(params) + if err != nil { + return err + } + + res, _, err := cliCtx.QueryWithData(fmt.Sprintf("custom/%s/nft", queryRoute), bz) + if err != nil { + return err + } + + var out exported.NFT + err = cdc.UnmarshalJSON(res, &out) + if err != nil { + return err + } + + return cliCtx.PrintOutput(out) + }, + } +} diff --git a/x/nft/client/cli/tx.go b/x/nft/client/cli/tx.go new file mode 100644 index 000000000000..41042b19858c --- /dev/null +++ b/x/nft/client/cli/tx.go @@ -0,0 +1,188 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/client/context" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/version" + authtypes "github.com/cosmos/cosmos-sdk/x/auth" + "github.com/cosmos/cosmos-sdk/x/auth/client/utils" + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" +) + +// Edit metadata flags +const ( + flagTokenURI = "tokenURI" +) + +// GetTxCmd returns the transaction commands for this module +func GetTxCmd(storeKey string, cdc *codec.Codec) *cobra.Command { + nftTxCmd := &cobra.Command{ + Use: types.ModuleName, + Short: "NFT transactions subcommands", + } + + nftTxCmd.AddCommand(client.PostCommands( + GetCmdTransferNFT(cdc), + GetCmdEditNFTMetadata(cdc), + GetCmdMintNFT(cdc), + GetCmdBurnNFT(cdc), + )...) + + return nftTxCmd +} + +// GetCmdTransferNFT is the CLI command for sending a TransferNFT transaction +func GetCmdTransferNFT(cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "transfer [sender] [recipient] [denom] [tokenID]", + Short: "transfer a NFT to a recipient", + Long: strings.TrimSpace( + fmt.Sprintf(`Transfer a NFT from a given collection that has a + specific id (SHA-256 hex hash) to a specific recipient. + +Example: +$ %s tx %s transfer +cosmos1gghjut3ccd8ay0zduzj64hwre2fxs9ld75ru9p cosmos1l2rsakp388kuv9k8qzq6lrm9taddae7fpx59wm \ +cripto-kitties d04b98f48e8f8bcc15c6ae5ac050801cd6dcfd428fb5f9e65c4e16e7807340fa \ +--from mykey +`, + version.ClientName, types.ModuleName, + ), + ), + Args: cobra.ExactArgs(4), + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + txBldr := authtypes.NewTxBuilderFromCLI().WithTxEncoder(utils.GetTxEncoder(cdc)) + + sender, err := sdk.AccAddressFromBech32(args[0]) + if err != nil { + return err + } + + recipient, err := sdk.AccAddressFromBech32(args[1]) + if err != nil { + return err + } + + denom := args[2] + tokenID := args[3] + + msg := types.NewMsgTransferNFT(sender, recipient, denom, tokenID) + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } +} + +// GetCmdEditNFTMetadata is the CLI command for sending an EditMetadata transaction +func GetCmdEditNFTMetadata(cdc *codec.Codec) *cobra.Command { + cmd := &cobra.Command{ + Use: "edit-metadata [denom] [tokenID]", + Short: "edit the metadata of an NFT", + Long: strings.TrimSpace( + fmt.Sprintf(`Edit the metadata of an NFT from a given collection that has a + specific id (SHA-256 hex hash). + +Example: +$ %s tx %s edit-metadata cripto-kitties d04b98f48e8f8bcc15c6ae5ac050801cd6dcfd428fb5f9e65c4e16e7807340fa \ +--tokenURI path_to_token_URI_JSON --from mykey +`, + version.ClientName, types.ModuleName, + ), + ), + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + txBldr := authtypes.NewTxBuilderFromCLI().WithTxEncoder(utils.GetTxEncoder(cdc)) + + denom := args[0] + tokenID := args[1] + tokenURI := viper.GetString(flagTokenURI) + + msg := types.NewMsgEditNFTMetadata(cliCtx.GetFromAddress(), tokenID, denom, tokenURI) + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } + + cmd.Flags().String(flagTokenURI, "", "Extra properties available for querying") + return cmd +} + +// GetCmdMintNFT is the CLI command for a MintNFT transaction +func GetCmdMintNFT(cdc *codec.Codec) *cobra.Command { + cmd := &cobra.Command{ + Use: "mint [denom] [tokenID] [recipient]", + Short: "mint an NFT and set the owner to the recipient", + Long: strings.TrimSpace( + fmt.Sprintf(`Mint an NFT from a given collection that has a + specific id (SHA-256 hex hash) and set the ownership to a specific address. + +Example: +$ %s tx %s mint cripto-kitties d04b98f48e8f8bcc15c6ae5ac050801cd6dcfd428fb5f9e65c4e16e7807340fa \ +cosmos1gghjut3ccd8ay0zduzj64hwre2fxs9ld75ru9p --from mykey +`, + version.ClientName, types.ModuleName, + ), + ), + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + txBldr := authtypes.NewTxBuilderFromCLI().WithTxEncoder(utils.GetTxEncoder(cdc)) + + denom := args[0] + tokenID := args[1] + + recipient, err := sdk.AccAddressFromBech32(args[2]) + if err != nil { + return err + } + + tokenURI := viper.GetString(flagTokenURI) + + msg := types.NewMsgMintNFT(cliCtx.GetFromAddress(), recipient, tokenID, denom, tokenURI) + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } + + cmd.Flags().String(flagTokenURI, "", "URI for supplemental off-chain metadata (should return a JSON object)") + + return cmd +} + +// GetCmdBurnNFT is the CLI command for sending a BurnNFT transaction +func GetCmdBurnNFT(cdc *codec.Codec) *cobra.Command { + return &cobra.Command{ + Use: "burn [denom] [tokenID]", + Short: "burn an NFT", + Long: strings.TrimSpace( + fmt.Sprintf(`Burn (i.e permanently delete) an NFT from a given collection that has a + specific id (SHA-256 hex hash). + +Example: +$ %s tx %s burn cripto-kitties d04b98f48e8f8bcc15c6ae5ac050801cd6dcfd428fb5f9e65c4e16e7807340fa \ +--from mykey +`, + version.ClientName, types.ModuleName, + ), + ), + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + cliCtx := context.NewCLIContext().WithCodec(cdc) + txBldr := authtypes.NewTxBuilderFromCLI().WithTxEncoder(utils.GetTxEncoder(cdc)) + + denom := args[0] + tokenID := args[1] + + msg := types.NewMsgBurnNFT(cliCtx.GetFromAddress(), tokenID, denom) + return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg}) + }, + } +} diff --git a/x/nft/client/rest/query.go b/x/nft/client/rest/query.go new file mode 100644 index 000000000000..00049085fbf8 --- /dev/null +++ b/x/nft/client/rest/query.go @@ -0,0 +1,177 @@ +package rest + +import ( + "fmt" + "net/http" + + "github.com/gorilla/mux" + + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/rest" + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" +) + +func registerQueryRoutes(cliCtx context.CLIContext, r *mux.Router, cdc *codec.Codec, queryRoute string) { + + // Get the total supply of a collection + r.HandleFunc( + "/nft/supply/{denom}", getSupply(cdc, cliCtx, queryRoute), + ).Methods("GET") + + // Get the collections of NFTs owned by an address + r.HandleFunc( + "/nft/owner/{delegatorAddr}", getOwner(cdc, cliCtx, queryRoute), + ).Methods("GET") + + // Get the NFTs owned by an address from a given collection + r.HandleFunc( + "/nft/owner/{delegatorAddr}/collection/{denom}", getOwnerByDenom(cdc, cliCtx, queryRoute), + ).Methods("GET") + + // Get all the NFT from a given collection + r.HandleFunc( + "/nft/collection/{denom}", getCollection(cdc, cliCtx, queryRoute), + ).Methods("GET") + + // Query all denoms + r.HandleFunc( + "/nft/denoms", getDenoms(cdc, cliCtx, queryRoute), + ).Methods("GET") + + // Query a single NFT + r.HandleFunc( + "/nft/collection/{denom}/nft/{id}", getNFT(cdc, cliCtx, queryRoute), + ).Methods("GET") +} + +func getSupply(cdc *codec.Codec, cliCtx context.CLIContext, queryRoute string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + denom := mux.Vars(r)["denom"] + + params := types.NewQueryCollectionParams(denom) + bz, err := cdc.MarshalJSON(params) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + res, _, err := cliCtx.QueryWithData(fmt.Sprintf("custom/%s/supply/%s", queryRoute, denom), bz) + if err != nil { + rest.WriteErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + rest.PostProcessResponse(w, cliCtx, res) + } +} + +func getOwner(cdc *codec.Codec, cliCtx context.CLIContext, queryRoute string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + address, err := sdk.AccAddressFromBech32(mux.Vars(r)["delegatorAddr"]) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + params := types.NewQueryBalanceParams(address, "") + bz, err := cdc.MarshalJSON(params) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + res, _, err := cliCtx.QueryWithData(fmt.Sprintf("custom/%s/owner", queryRoute), bz) + if err != nil { + rest.WriteErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + rest.PostProcessResponse(w, cliCtx, res) + } +} + +func getOwnerByDenom(cdc *codec.Codec, cliCtx context.CLIContext, queryRoute string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + denom := vars["denom"] + address, err := sdk.AccAddressFromBech32(vars["delegatorAddr"]) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + params := types.NewQueryBalanceParams(address, denom) + bz, err := cdc.MarshalJSON(params) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + res, _, err := cliCtx.QueryWithData(fmt.Sprintf("custom/%s/ownerByDenom", queryRoute), bz) + if err != nil { + rest.WriteErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + rest.PostProcessResponse(w, cliCtx, res) + } +} + +func getCollection(cdc *codec.Codec, cliCtx context.CLIContext, queryRoute string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + denom := mux.Vars(r)["denom"] + + params := types.NewQueryCollectionParams(denom) + bz, err := cdc.MarshalJSON(params) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + res, _, err := cliCtx.QueryWithData(fmt.Sprintf("custom/%s/collection", queryRoute), bz) + if err != nil { + rest.WriteErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + rest.PostProcessResponse(w, cliCtx, res) + } +} + +func getDenoms(cdc *codec.Codec, cliCtx context.CLIContext, queryRoute string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + + res, _, err := cliCtx.QueryWithData(fmt.Sprintf("custom/%s/denoms", queryRoute), nil) + if err != nil { + rest.WriteErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + rest.PostProcessResponse(w, cliCtx, res) + } +} + +func getNFT(cdc *codec.Codec, cliCtx context.CLIContext, queryRoute string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + denom := vars["denom"] + id := vars["id"] + + params := types.NewQueryNFTParams(denom, id) + bz, err := cdc.MarshalJSON(params) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + res, _, err := cliCtx.QueryWithData(fmt.Sprintf("custom/%s/nft", queryRoute), bz) + if err != nil { + rest.WriteErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + rest.PostProcessResponse(w, cliCtx, res) + } +} diff --git a/x/nft/client/rest/rest.go b/x/nft/client/rest/rest.go new file mode 100644 index 000000000000..6596ccdf3e1d --- /dev/null +++ b/x/nft/client/rest/rest.go @@ -0,0 +1,14 @@ +package rest + +import ( + "github.com/gorilla/mux" + + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/codec" +) + +// RegisterRoutes register distribution REST routes. +func RegisterRoutes(cliCtx context.CLIContext, r *mux.Router, cdc *codec.Codec, queryRoute string) { + registerQueryRoutes(cliCtx, r, cdc, queryRoute) + registerTxRoutes(cliCtx, r, cdc, queryRoute) +} diff --git a/x/nft/client/rest/tx.go b/x/nft/client/rest/tx.go new file mode 100644 index 000000000000..0856138c9805 --- /dev/null +++ b/x/nft/client/rest/tx.go @@ -0,0 +1,149 @@ +package rest + +import ( + "net/http" + + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/rest" + "github.com/cosmos/cosmos-sdk/x/auth/client/utils" + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" + + "github.com/gorilla/mux" +) + +func registerTxRoutes(cliCtx context.CLIContext, r *mux.Router, + cdc *codec.Codec, queryRoute string) { + + // Transfer an NFT to an address + r.HandleFunc( + "/nfts/transfer", + transferNFTHandler(cdc, cliCtx), + ).Methods("POST") + + // Update an NFT metadata + r.HandleFunc( + "/nfts/collection/{denom}/nft/{id}/metadata", + editNFTMetadataHandler(cdc, cliCtx), + ).Methods("PUT") + + // Mint an NFT + r.HandleFunc( + "/nfts/mint", + mintNFTHandler(cdc, cliCtx), + ).Methods("POST") + + // Burn an NFT + r.HandleFunc( + "/nfts/collection/{denom}/nft/{id}/burn", + burnNFTHandler(cdc, cliCtx), + ).Methods("PUT") +} + +type transferNFTReq struct { + BaseReq rest.BaseReq `json:"base_req"` + Denom string `json:"denom"` + ID string `json:"id"` + Recipient string `json:"recipient"` +} + +func transferNFTHandler(cdc *codec.Codec, cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req transferNFTReq + if !rest.ReadRESTReq(w, r, cdc, &req) { + rest.WriteErrorResponse(w, http.StatusBadRequest, "failed to parse request") + return + } + baseReq := req.BaseReq.Sanitize() + if !baseReq.ValidateBasic(w) { + return + } + recipient, err := sdk.AccAddressFromBech32(req.Recipient) + if err != nil { + rest.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + // create the message + msg := types.NewMsgTransferNFT(cliCtx.GetFromAddress(), recipient, req.Denom, req.ID) + + utils.WriteGenerateStdTxResponse(w, cliCtx, baseReq, []sdk.Msg{msg}) + } +} + +type editNFTMetadataReq struct { + BaseReq rest.BaseReq `json:"base_req"` + Denom string `json:"denom"` + ID string `json:"id"` + TokenURI string `json:"tokenURI"` +} + +func editNFTMetadataHandler(cdc *codec.Codec, cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req editNFTMetadataReq + if !rest.ReadRESTReq(w, r, cdc, &req) { + rest.WriteErrorResponse(w, http.StatusBadRequest, "failed to parse request") + return + } + baseReq := req.BaseReq.Sanitize() + if !baseReq.ValidateBasic(w) { + return + } + + // create the message + msg := types.NewMsgEditNFTMetadata(cliCtx.GetFromAddress(), req.ID, req.Denom, req.TokenURI) + + utils.WriteGenerateStdTxResponse(w, cliCtx, baseReq, []sdk.Msg{msg}) + } +} + +type mintNFTReq struct { + BaseReq rest.BaseReq `json:"base_req"` + Recipient sdk.AccAddress `json:"recipient"` + Denom string `json:"denom"` + ID string `json:"id"` + TokenURI string `json:"tokenURI"` +} + +func mintNFTHandler(cdc *codec.Codec, cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req mintNFTReq + if !rest.ReadRESTReq(w, r, cdc, &req) { + rest.WriteErrorResponse(w, http.StatusBadRequest, "failed to parse request") + return + } + baseReq := req.BaseReq.Sanitize() + if !baseReq.ValidateBasic(w) { + return + } + + // create the message + msg := types.NewMsgMintNFT(cliCtx.GetFromAddress(), req.Recipient, req.ID, req.Denom, req.TokenURI) + + utils.WriteGenerateStdTxResponse(w, cliCtx, baseReq, []sdk.Msg{msg}) + } +} + +type burnNFTReq struct { + BaseReq rest.BaseReq `json:"base_req"` + Denom string `json:"denom"` + ID string `json:"id"` +} + +func burnNFTHandler(cdc *codec.Codec, cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req burnNFTReq + if !rest.ReadRESTReq(w, r, cdc, &req) { + rest.WriteErrorResponse(w, http.StatusBadRequest, "failed to parse request") + return + } + baseReq := req.BaseReq.Sanitize() + if !baseReq.ValidateBasic(w) { + return + } + + // create the message + msg := types.NewMsgBurnNFT(cliCtx.GetFromAddress(), req.ID, req.Denom) + utils.WriteGenerateStdTxResponse(w, cliCtx, baseReq, []sdk.Msg{msg}) + } +} diff --git a/x/nft/exported/nft.go b/x/nft/exported/nft.go new file mode 100644 index 000000000000..8cf60e70368d --- /dev/null +++ b/x/nft/exported/nft.go @@ -0,0 +1,15 @@ +package exported + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// NFT non fungible token interface +type NFT interface { + GetID() string + GetOwner() sdk.AccAddress + SetOwner(address sdk.AccAddress) + GetTokenURI() string + EditMetadata(tokenURI string) + String() string +} diff --git a/x/nft/genesis.go b/x/nft/genesis.go new file mode 100644 index 000000000000..e6d852633e60 --- /dev/null +++ b/x/nft/genesis.go @@ -0,0 +1,20 @@ +package nft + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// InitGenesis sets nft information for genesis. +func InitGenesis(ctx sdk.Context, k Keeper, data GenesisState) { + k.SetOwners(ctx, data.Owners) + + for _, c := range data.Collections { + k.SetCollection(ctx, c.Denom, c) + } + +} + +// ExportGenesis returns a GenesisState for a given context and keeper. +func ExportGenesis(ctx sdk.Context, k Keeper) GenesisState { + return NewGenesisState(k.GetOwners(ctx), k.GetCollections(ctx)) +} diff --git a/x/nft/genesis_test.go b/x/nft/genesis_test.go new file mode 100644 index 000000000000..3541ef3b2e7c --- /dev/null +++ b/x/nft/genesis_test.go @@ -0,0 +1,62 @@ +package nft_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/cosmos-sdk/x/nft" +) + +func TestInitGenesis(t *testing.T) { + app, ctx := createTestApp(false) + genesisState := nft.DefaultGenesisState() + require.Equal(t, 0, len(genesisState.Owners)) + require.Equal(t, 0, len(genesisState.Collections)) + + ids := []string{id, id2, id3} + idCollection := nft.NewIDCollection(denom, ids) + idCollection2 := nft.NewIDCollection(denom2, ids) + owner := nft.NewOwner(address, idCollection) + + owner2 := nft.NewOwner(address2, idCollection2) + + owners := []nft.Owner{owner, owner2} + + nft1 := nft.NewBaseNFT(id, address, tokenURI1) + nft2 := nft.NewBaseNFT(id2, address, tokenURI1) + nft3 := nft.NewBaseNFT(id3, address, tokenURI1) + nfts := nft.NewNFTs(&nft1, &nft2, &nft3) + collection := nft.NewCollection(denom, nfts) + + nftx := nft.NewBaseNFT(id, address2, tokenURI1) + nft2x := nft.NewBaseNFT(id2, address2, tokenURI1) + nft3x := nft.NewBaseNFT(id3, address2, tokenURI1) + nftsx := nft.NewNFTs(&nftx, &nft2x, &nft3x) + collection2 := nft.NewCollection(denom2, nftsx) + + collections := nft.NewCollections(collection, collection2) + + genesisState = nft.NewGenesisState(owners, collections) + + nft.InitGenesis(ctx, app.NFTKeeper, genesisState) + + returnedOwners := app.NFTKeeper.GetOwners(ctx) + require.Equal(t, 2, len(owners)) + require.Equal(t, returnedOwners[0].String(), owners[0].String()) + require.Equal(t, returnedOwners[1].String(), owners[1].String()) + + returnedCollections := app.NFTKeeper.GetCollections(ctx) + require.Equal(t, 2, len(returnedCollections)) + require.Equal(t, returnedCollections[0].String(), collections[0].String()) + require.Equal(t, returnedCollections[1].String(), collections[1].String()) + + exportedGenesisState := nft.ExportGenesis(ctx, app.NFTKeeper) + require.Equal(t, len(genesisState.Owners), len(exportedGenesisState.Owners)) + require.Equal(t, genesisState.Owners[0].String(), exportedGenesisState.Owners[0].String()) + require.Equal(t, genesisState.Owners[1].String(), exportedGenesisState.Owners[1].String()) + + require.Equal(t, len(genesisState.Collections), len(exportedGenesisState.Collections)) + require.Equal(t, genesisState.Collections[0].String(), exportedGenesisState.Collections[0].String()) + require.Equal(t, genesisState.Collections[1].String(), exportedGenesisState.Collections[1].String()) +} diff --git a/x/nft/handler.go b/x/nft/handler.go new file mode 100644 index 000000000000..0188f59ee285 --- /dev/null +++ b/x/nft/handler.go @@ -0,0 +1,157 @@ +package nft + +import ( + "fmt" + + abci "github.com/tendermint/tendermint/abci/types" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/nft/internal/keeper" + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" +) + +// GenericHandler routes the messages to the handlers +func GenericHandler(k keeper.Keeper) sdk.Handler { + return func(ctx sdk.Context, msg sdk.Msg) sdk.Result { + switch msg := msg.(type) { + case types.MsgTransferNFT: + return HandleMsgTransferNFT(ctx, msg, k) + case types.MsgEditNFTMetadata: + return HandleMsgEditNFTMetadata(ctx, msg, k) + case types.MsgMintNFT: + return HandleMsgMintNFT(ctx, msg, k) + case types.MsgBurnNFT: + return HandleMsgBurnNFT(ctx, msg, k) + default: + errMsg := fmt.Sprintf("unrecognized nft message type: %T", msg) + return sdk.ErrUnknownRequest(errMsg).Result() + } + } +} + +// HandleMsgTransferNFT handler for MsgTransferNFT +func HandleMsgTransferNFT(ctx sdk.Context, msg types.MsgTransferNFT, k keeper.Keeper, +) sdk.Result { + + nft, err := k.GetNFT(ctx, msg.Denom, msg.ID) + if err != nil { + return err.Result() + } + // update NFT owner + nft.SetOwner(msg.Recipient) + // update the NFT (owners are updated within the keeper) + err = k.UpdateNFT(ctx, msg.Denom, nft) + if err != nil { + return err.Result() + } + + ctx.EventManager().EmitEvents(sdk.Events{ + sdk.NewEvent( + types.EventTypeTransfer, + sdk.NewAttribute(types.AttributeKeyRecipient, msg.Recipient.String()), + sdk.NewAttribute(types.AttributeKeyDenom, msg.Denom), + sdk.NewAttribute(types.AttributeKeyNFTID, msg.ID), + ), + sdk.NewEvent( + sdk.EventTypeMessage, + sdk.NewAttribute(sdk.AttributeKeyModule, types.AttributeValueCategory), + sdk.NewAttribute(sdk.AttributeKeySender, msg.Sender.String()), + ), + }) + return sdk.Result{Events: ctx.EventManager().Events()} +} + +// HandleMsgEditNFTMetadata handler for MsgEditNFTMetadata +func HandleMsgEditNFTMetadata(ctx sdk.Context, msg types.MsgEditNFTMetadata, k keeper.Keeper, +) sdk.Result { + + nft, err := k.GetNFT(ctx, msg.Denom, msg.ID) + if err != nil { + return err.Result() + } + + // update NFT + nft.EditMetadata(msg.TokenURI) + err = k.UpdateNFT(ctx, msg.Denom, nft) + if err != nil { + return err.Result() + } + + ctx.EventManager().EmitEvents(sdk.Events{ + sdk.NewEvent( + types.EventTypeEditNFTMetadata, + sdk.NewAttribute(types.AttributeKeyDenom, msg.Denom), + sdk.NewAttribute(types.AttributeKeyNFTID, msg.ID), + sdk.NewAttribute(types.AttributeKeyNFTTokenURI, msg.TokenURI), + ), + sdk.NewEvent( + sdk.EventTypeMessage, + sdk.NewAttribute(sdk.AttributeKeyModule, types.AttributeValueCategory), + sdk.NewAttribute(sdk.AttributeKeySender, msg.Sender.String()), + ), + }) + return sdk.Result{Events: ctx.EventManager().Events()} +} + +// HandleMsgMintNFT handles MsgMintNFT +func HandleMsgMintNFT(ctx sdk.Context, msg types.MsgMintNFT, k keeper.Keeper, +) sdk.Result { + + nft := types.NewBaseNFT(msg.ID, msg.Recipient, msg.TokenURI) + err := k.MintNFT(ctx, msg.Denom, &nft) + if err != nil { + return err.Result() + } + + ctx.EventManager().EmitEvents(sdk.Events{ + sdk.NewEvent( + types.EventTypeMintNFT, + sdk.NewAttribute(types.AttributeKeyRecipient, msg.Recipient.String()), + sdk.NewAttribute(types.AttributeKeyDenom, msg.Denom), + sdk.NewAttribute(types.AttributeKeyNFTID, msg.ID), + sdk.NewAttribute(types.AttributeKeyNFTTokenURI, msg.TokenURI), + ), + sdk.NewEvent( + sdk.EventTypeMessage, + sdk.NewAttribute(sdk.AttributeKeyModule, types.AttributeValueCategory), + sdk.NewAttribute(sdk.AttributeKeySender, msg.Sender.String()), + ), + }) + return sdk.Result{Events: ctx.EventManager().Events()} + +} + +// HandleMsgBurnNFT handles MsgBurnNFT +func HandleMsgBurnNFT(ctx sdk.Context, msg types.MsgBurnNFT, k keeper.Keeper, +) sdk.Result { + + _, err := k.GetNFT(ctx, msg.Denom, msg.ID) + if err != nil { + return err.Result() + } + + // remove NFT + err = k.DeleteNFT(ctx, msg.Denom, msg.ID) + if err != nil { + return err.Result() + } + + ctx.EventManager().EmitEvents(sdk.Events{ + sdk.NewEvent( + types.EventTypeBurnNFT, + sdk.NewAttribute(types.AttributeKeyDenom, msg.Denom), + sdk.NewAttribute(types.AttributeKeyNFTID, msg.ID), + ), + sdk.NewEvent( + sdk.EventTypeMessage, + sdk.NewAttribute(sdk.AttributeKeyModule, types.AttributeValueCategory), + sdk.NewAttribute(sdk.AttributeKeySender, msg.Sender.String()), + ), + }) + return sdk.Result{Events: ctx.EventManager().Events()} +} + +// EndBlocker is run at the end of the block +func EndBlocker(ctx sdk.Context, k keeper.Keeper) []abci.ValidatorUpdate { + return nil +} diff --git a/x/nft/handler_test.go b/x/nft/handler_test.go new file mode 100644 index 000000000000..64cb5a019e47 --- /dev/null +++ b/x/nft/handler_test.go @@ -0,0 +1,252 @@ +package nft_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/nft" + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" +) + +const ( + module = "module" + denom = "denom" + nftID = "nft-id" + sender = "sender" + recipient = "recipient" + tokenURI = "token-uri" +) + +func TestInvalidMsg(t *testing.T) { + app, ctx := createTestApp(false) + h := nft.GenericHandler(app.NFTKeeper) + res := h(ctx, sdk.NewTestMsg()) + require.False(t, res.IsOK()) + require.True(t, strings.Contains(res.Log, "unrecognized nft message type")) +} + +func TestTransferNFTMsg(t *testing.T) { + + app, ctx := createTestApp(false) + h := nft.GenericHandler(app.NFTKeeper) + + // An NFT to be transferred + nft := types.NewBaseNFT(id, address, "TokenURI") + + // Define MsgTransferNft + transferNftMsg := types.NewMsgTransferNFT(address, address2, denom, id) + + // handle should fail trying to transfer NFT that doesn't exist + res := h(ctx, transferNftMsg) + require.False(t, res.IsOK(), "%v", res) + + // Create token (collection and owner) + app.NFTKeeper.MintNFT(ctx, denom, &nft) + require.True(t, CheckInvariants(app.NFTKeeper, ctx)) + + // handle should succeed when nft exists and is transferred by owner + res = h(ctx, transferNftMsg) + require.True(t, res.IsOK(), "%v", res) + require.True(t, CheckInvariants(app.NFTKeeper, ctx)) + + // event events should be emitted correctly + for _, event := range res.Events { + for _, attribute := range event.Attributes { + value := string(attribute.Value) + switch key := string(attribute.Key); key { + case module: + require.Equal(t, value, types.ModuleName) + case denom: + require.Equal(t, value, denom) + case nftID: + require.Equal(t, value, id) + case sender: + require.Equal(t, value, address.String()) + case recipient: + require.Equal(t, value, address2.String()) + default: + require.Fail(t, fmt.Sprintf("unrecognized event %s", key)) + } + } + } + + // nft should have been transferred as a result of the message + nftAfterwards, err := app.NFTKeeper.GetNFT(ctx, denom, id) + require.NoError(t, err) + require.True(t, nftAfterwards.GetOwner().Equals(address2)) + + transferNftMsg = types.NewMsgTransferNFT(address2, address3, denom, id) + + // handle should succeed when nft exists and is transferred by owner + res = h(ctx, transferNftMsg) + require.True(t, res.IsOK(), "%v", res) + require.True(t, CheckInvariants(app.NFTKeeper, ctx)) + + // Create token (collection and owner) + app.NFTKeeper.MintNFT(ctx, denom2, &nft) + require.True(t, CheckInvariants(app.NFTKeeper, ctx)) + + transferNftMsg = types.NewMsgTransferNFT(address2, address3, denom2, id) + + // handle should succeed when nft exists and is transferred by owner + res = h(ctx, transferNftMsg) + require.True(t, res.IsOK(), "%v", res) + require.True(t, CheckInvariants(app.NFTKeeper, ctx)) +} + +func TestEditNFTMetadataMsg(t *testing.T) { + app, ctx := createTestApp(false) + h := nft.GenericHandler(app.NFTKeeper) + + // An NFT to be edited + nft := types.NewBaseNFT(id, address, tokenURI) + + // Create token (collection and address) + app.NFTKeeper.MintNFT(ctx, denom, &nft) + + // Define MsgTransferNft + failingEditNFTMetadata := types.NewMsgEditNFTMetadata(address, id, denom2, tokenURI2) + + res := h(ctx, failingEditNFTMetadata) + require.False(t, res.IsOK(), "%v", res) + + // Define MsgTransferNft + editNFTMetadata := types.NewMsgEditNFTMetadata(address, id, denom, tokenURI2) + + res = h(ctx, editNFTMetadata) + require.True(t, res.IsOK(), "%v", res) + + // event events should be emitted correctly + for _, event := range res.Events { + for _, attribute := range event.Attributes { + value := string(attribute.Value) + switch key := string(attribute.Key); key { + case module: + require.Equal(t, value, types.ModuleName) + case denom: + require.Equal(t, value, denom) + case nftID: + require.Equal(t, value, id) + case sender: + require.Equal(t, value, address.String()) + case tokenURI: + require.Equal(t, value, tokenURI2) + default: + require.Fail(t, fmt.Sprintf("unrecognized event %s", key)) + } + } + } + + nftAfterwards, err := app.NFTKeeper.GetNFT(ctx, denom, id) + require.NoError(t, err) + require.Equal(t, tokenURI2, nftAfterwards.GetTokenURI()) + +} + +func TestMintNFTMsg(t *testing.T) { + app, ctx := createTestApp(false) + h := nft.GenericHandler(app.NFTKeeper) + + // Define MsgMintNFT + mintNFT := types.NewMsgMintNFT(address, address, id, denom, tokenURI) + + // minting a token should succeed + res := h(ctx, mintNFT) + require.True(t, res.IsOK(), "%v", res) + + // event events should be emitted correctly + for _, event := range res.Events { + for _, attribute := range event.Attributes { + value := string(attribute.Value) + switch key := string(attribute.Key); key { + case module: + require.Equal(t, value, types.ModuleName) + case denom: + require.Equal(t, value, denom) + case nftID: + require.Equal(t, value, id) + case sender: + require.Equal(t, value, address.String()) + case recipient: + require.Equal(t, value, address.String()) + case tokenURI: + require.Equal(t, value, tokenURI) + default: + require.Fail(t, fmt.Sprintf("unrecognized event %s", key)) + } + } + } + + nftAfterwards, err := app.NFTKeeper.GetNFT(ctx, denom, id) + + require.NoError(t, err) + require.Equal(t, tokenURI, nftAfterwards.GetTokenURI()) + + // minting the same token should fail + res = h(ctx, mintNFT) + require.False(t, res.IsOK(), "%v", res) + + require.True(t, CheckInvariants(app.NFTKeeper, ctx)) + +} + +func TestBurnNFTMsg(t *testing.T) { + app, ctx := createTestApp(false) + h := nft.GenericHandler(app.NFTKeeper) + + // An NFT to be burned + nft := types.NewBaseNFT(id, address, tokenURI) + + // Create token (collection and address) + app.NFTKeeper.MintNFT(ctx, denom, &nft) + + exists := app.NFTKeeper.IsNFT(ctx, denom, id) + require.True(t, exists) + + // burning a non-existent NFT should fail + failBurnNFT := types.NewMsgBurnNFT(address, id2, denom) + res := h(ctx, failBurnNFT) + require.False(t, res.IsOK(), "%s", res.Log) + + // NFT should still exist + exists = app.NFTKeeper.IsNFT(ctx, denom, id) + require.True(t, exists) + + // burning the NFt should succeed + burnNFT := types.NewMsgBurnNFT(address, id, denom) + + res = h(ctx, burnNFT) + require.True(t, res.IsOK(), "%v", res) + + // event events should be emitted correctly + for _, event := range res.Events { + for _, attribute := range event.Attributes { + value := string(attribute.Value) + switch key := string(attribute.Key); key { + case module: + require.Equal(t, value, types.ModuleName) + case denom: + require.Equal(t, value, denom) + case nftID: + require.Equal(t, value, id) + case sender: + require.Equal(t, value, address.String()) + default: + require.Fail(t, fmt.Sprintf("unrecognized event %s", key)) + } + } + } + + // the NFT should not exist after burn + exists = app.NFTKeeper.IsNFT(ctx, denom, id) + require.False(t, exists) + + ownerReturned := app.NFTKeeper.GetOwner(ctx, address) + require.Equal(t, 0, ownerReturned.Supply()) + + require.True(t, CheckInvariants(app.NFTKeeper, ctx)) +} diff --git a/x/nft/integration_test.go b/x/nft/integration_test.go new file mode 100644 index 000000000000..3a893004fc5a --- /dev/null +++ b/x/nft/integration_test.go @@ -0,0 +1,61 @@ +package nft_test + +import ( + "fmt" + + abci "github.com/tendermint/tendermint/abci/types" + + "github.com/cosmos/cosmos-sdk/simapp" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/nft" + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" +) + +// nolint: deadcode unused +var ( + denom1 = "test-denom" + denom2 = "test-denom2" + denom3 = "test-denom3" + id = "1" + id2 = "2" + id3 = "3" + address = types.CreateTestAddrs(1)[0] + address2 = types.CreateTestAddrs(2)[1] + address3 = types.CreateTestAddrs(3)[2] + tokenURI1 = "https://google.com/token-1.json" + tokenURI2 = "https://google.com/token-2.json" +) + +func createTestApp(isCheckTx bool) (*simapp.SimApp, sdk.Context) { + app := simapp.Setup(isCheckTx) + ctx := app.BaseApp.NewContext(isCheckTx, abci.Header{}) + + return app, ctx +} + +// CheckInvariants checks the invariants +func CheckInvariants(k nft.Keeper, ctx sdk.Context) bool { + + collectionsSupply := make(map[string]int) + ownersCollectionsSupply := make(map[string]int) + + k.IterateCollections(ctx, func(collection types.Collection) bool { + collectionsSupply[collection.Denom] = collection.Supply() + return false + }) + + owners := k.GetOwners(ctx) + for _, owner := range owners { + for _, idCollection := range owner.IDCollections { + ownersCollectionsSupply[idCollection.Denom] += idCollection.Supply() + } + } + + for denom, supply := range collectionsSupply { + if supply != ownersCollectionsSupply[denom] { + fmt.Printf("denom is %s, supply is %d, ownerSupply is %d", denom, supply, ownersCollectionsSupply[denom]) + return false + } + } + return true +} diff --git a/x/nft/internal/keeper/collection.go b/x/nft/internal/keeper/collection.go new file mode 100644 index 000000000000..62f42df21e02 --- /dev/null +++ b/x/nft/internal/keeper/collection.go @@ -0,0 +1,62 @@ +package keeper + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" +) + +// IterateCollections iterates over collections and performs a function +func (k Keeper) IterateCollections(ctx sdk.Context, handler func(collection types.Collection) (stop bool)) { + store := ctx.KVStore(k.storeKey) + iterator := sdk.KVStorePrefixIterator(store, types.CollectionsKeyPrefix) + defer iterator.Close() + for ; iterator.Valid(); iterator.Next() { + var collection types.Collection + k.cdc.MustUnmarshalBinaryLengthPrefixed(iterator.Value(), &collection) + if handler(collection) { + break + } + } +} + +// SetCollection sets the entire collection of a single denom +func (k Keeper) SetCollection(ctx sdk.Context, denom string, collection types.Collection) { + store := ctx.KVStore(k.storeKey) + collectionKey := types.GetCollectionKey(denom) + bz := k.cdc.MustMarshalBinaryLengthPrefixed(collection) + store.Set(collectionKey, bz) +} + +// GetCollection returns a collection of NFTs +func (k Keeper) GetCollection(ctx sdk.Context, denom string) (collection types.Collection, found bool) { + store := ctx.KVStore(k.storeKey) + collectionKey := types.GetCollectionKey(denom) + bz := store.Get(collectionKey) + if bz == nil { + return + } + k.cdc.MustUnmarshalBinaryLengthPrefixed(bz, &collection) + return collection, true +} + +// GetCollections returns all the NFTs collections +func (k Keeper) GetCollections(ctx sdk.Context) (collections []types.Collection) { + k.IterateCollections(ctx, + func(collection types.Collection) (stop bool) { + collections = append(collections, collection) + return false + }, + ) + return +} + +// GetDenoms returns all the NFT denoms +func (k Keeper) GetDenoms(ctx sdk.Context) (denoms []string) { + k.IterateCollections(ctx, + func(collection types.Collection) (stop bool) { + denoms = append(denoms, collection.Denom) + return false + }, + ) + return +} diff --git a/x/nft/internal/keeper/collection_test.go b/x/nft/internal/keeper/collection_test.go new file mode 100644 index 000000000000..c4d4c71ade8a --- /dev/null +++ b/x/nft/internal/keeper/collection_test.go @@ -0,0 +1,67 @@ +package keeper_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" +) + +func TestSetCollection(t *testing.T) { + app, ctx := createTestApp(false) + + // MintNFT shouldn't fail when collection does not exist + nft := types.NewBaseNFT(id, address, tokenURI) + err := app.NFTKeeper.MintNFT(ctx, denom, &nft) + require.NoError(t, err) + + // collection should exist + collection, exists := app.NFTKeeper.GetCollection(ctx, denom) + require.True(t, exists) + + nft2 := types.NewBaseNFT(id2, address, tokenURI) + collection, err = collection.AddNFT(&nft2) + require.NoError(t, err) + app.NFTKeeper.SetCollection(ctx, denom, collection) + + collection, exists = app.NFTKeeper.GetCollection(ctx, denom) + require.True(t, exists) + require.Len(t, collection.NFTs, 2) + +} +func TestGetCollection(t *testing.T) { + app, ctx := createTestApp(false) + + // collection shouldn't exist + collection, exists := app.NFTKeeper.GetCollection(ctx, denom) + require.Empty(t, collection) + require.False(t, exists) + + // MintNFT shouldn't fail when collection does not exist + nft := types.NewBaseNFT(id, address, tokenURI) + err := app.NFTKeeper.MintNFT(ctx, denom, &nft) + require.NoError(t, err) + + // collection should exist + collection, exists = app.NFTKeeper.GetCollection(ctx, denom) + require.True(t, exists) + require.NotEmpty(t, collection) +} +func TestGetCollections(t *testing.T) { + app, ctx := createTestApp(false) + + // collections should be empty + collections := app.NFTKeeper.GetCollections(ctx) + require.Empty(t, collections) + + // MintNFT shouldn't fail when collection does not exist + nft := types.NewBaseNFT(id, address, tokenURI) + err := app.NFTKeeper.MintNFT(ctx, denom, &nft) + require.NoError(t, err) + + // collections should equal 1 + collections = app.NFTKeeper.GetCollections(ctx) + require.NotEmpty(t, collections) + require.Equal(t, len(collections), 1) +} diff --git a/x/nft/internal/keeper/integration_test.go b/x/nft/internal/keeper/integration_test.go new file mode 100644 index 000000000000..7580effac707 --- /dev/null +++ b/x/nft/internal/keeper/integration_test.go @@ -0,0 +1,31 @@ +package keeper_test + +import ( + abci "github.com/tendermint/tendermint/abci/types" + + "github.com/cosmos/cosmos-sdk/simapp" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" +) + +// nolint: deadcode unused +var ( + denom = "test-denom" + denom2 = "test-denom2" + denom3 = "test-denom3" + id = "1" + id2 = "2" + id3 = "3" + address = types.CreateTestAddrs(1)[0] + address2 = types.CreateTestAddrs(2)[1] + address3 = types.CreateTestAddrs(3)[2] + tokenURI = "https://google.com/token-1.json" + tokenURI2 = "https://google.com/token-2.json" +) + +func createTestApp(isCheckTx bool) (*simapp.SimApp, sdk.Context) { + app := simapp.Setup(isCheckTx) + ctx := app.BaseApp.NewContext(isCheckTx, abci.Header{}) + + return app, ctx +} diff --git a/x/nft/internal/keeper/invariants.go b/x/nft/internal/keeper/invariants.go new file mode 100644 index 000000000000..a8fd4d2b433d --- /dev/null +++ b/x/nft/internal/keeper/invariants.go @@ -0,0 +1,59 @@ +package keeper + +// DONTCOVER + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" +) + +// RegisterInvariants registers all supply invariants +func RegisterInvariants(ir sdk.InvariantRegistry, k Keeper) { + ir.RegisterRoute( + types.ModuleName, "supply", + SupplyInvariant(k), + ) +} + +// AllInvariants runs all invariants of the nfts module. +func AllInvariants(k Keeper) sdk.Invariant { + return func(ctx sdk.Context) (string, bool) { + return SupplyInvariant(k)(ctx) + } +} + +// SupplyInvariant checks that the total amount of nfts on collections matches the total amount owned by addresses +func SupplyInvariant(k Keeper) sdk.Invariant { + return func(ctx sdk.Context) (string, bool) { + collectionsSupply := make(map[string]int) + ownersCollectionsSupply := make(map[string]int) + var msg string + count := 0 + + k.IterateCollections(ctx, func(collection types.Collection) bool { + collectionsSupply[collection.Denom] = collection.Supply() + return false + }) + + for _, owner := range k.GetOwners(ctx) { + for _, idCollection := range owner.IDCollections { + ownersCollectionsSupply[idCollection.Denom] += idCollection.Supply() + } + } + + for denom, supply := range collectionsSupply { + if supply != ownersCollectionsSupply[denom] { + count++ + msg += fmt.Sprintf("total %s NFTs supply invariance:\n"+ + "\ttotal %s NFTs supply: %d\n"+ + "\tsum of %s NFTs by owner: %d\n", denom, denom, supply, denom, ownersCollectionsSupply[denom]) + } + } + broken := count != 0 + + return sdk.FormatInvariant(types.ModuleName, "supply", fmt.Sprintf( + "%d NFT supply invariants found\n%s", count, msg)), broken + } +} diff --git a/x/nft/internal/keeper/keeper.go b/x/nft/internal/keeper/keeper.go new file mode 100644 index 000000000000..362ebd7522b7 --- /dev/null +++ b/x/nft/internal/keeper/keeper.go @@ -0,0 +1,32 @@ +package keeper + +import ( + "fmt" + + "github.com/tendermint/tendermint/libs/log" + + "github.com/cosmos/cosmos-sdk/codec" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" +) + +// Keeper maintains the link to data storage and exposes getter/setter methods for the various parts of the state machine +type Keeper struct { + storeKey sdk.StoreKey // Unexposed key to access store from sdk.Context + + cdc *codec.Codec // The amino codec for binary encoding/decoding. +} + +// NewKeeper creates new instances of the nft Keeper +func NewKeeper(cdc *codec.Codec, storeKey sdk.StoreKey) Keeper { + return Keeper{ + storeKey: storeKey, + cdc: cdc, + } +} + +// Logger returns a module-specific logger. +func (k Keeper) Logger(ctx sdk.Context) log.Logger { + return ctx.Logger().With("module", fmt.Sprintf("x/%s", types.ModuleName)) +} diff --git a/x/nft/internal/keeper/nft.go b/x/nft/internal/keeper/nft.go new file mode 100644 index 000000000000..182ed91f4257 --- /dev/null +++ b/x/nft/internal/keeper/nft.go @@ -0,0 +1,108 @@ +package keeper + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/nft/exported" + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" +) + +// IsNFT returns whether an NFT exists +func (k Keeper) IsNFT(ctx sdk.Context, denom, id string) (exists bool) { + _, err := k.GetNFT(ctx, denom, id) + return err == nil +} + +// GetNFT gets the entire NFT metadata struct for a uint64 +func (k Keeper) GetNFT(ctx sdk.Context, denom, id string) (nft exported.NFT, err sdk.Error) { + collection, found := k.GetCollection(ctx, denom) + if !found { + return nil, types.ErrUnknownCollection(types.DefaultCodespace, fmt.Sprintf("collection of %s doesn't exist", denom)) + } + nft, err = collection.GetNFT(id) + + if err != nil { + return nil, err + } + return nft, err +} + +// UpdateNFT updates an already existing NFTs +func (k Keeper) UpdateNFT(ctx sdk.Context, denom string, nft exported.NFT) (err sdk.Error) { + collection, found := k.GetCollection(ctx, denom) + if !found { + return types.ErrUnknownCollection(types.DefaultCodespace, + fmt.Sprintf("collection #%s doesn't exist", denom), + ) + } + oldNFT, err := collection.GetNFT(nft.GetID()) + if err != nil { + return err + } + // if the owner changed then update the owners KVStore too + if !oldNFT.GetOwner().Equals(nft.GetOwner()) { + err = k.SwapOwners(ctx, denom, nft.GetID(), oldNFT.GetOwner(), nft.GetOwner()) + if err != nil { + return err + } + } + collection, err = collection.UpdateNFT(nft) + + if err != nil { + return err + } + k.SetCollection(ctx, denom, collection) + return nil +} + +// MintNFT mints an NFT and manages that NFTs existence within Collections and Owners +func (k Keeper) MintNFT(ctx sdk.Context, denom string, nft exported.NFT) (err sdk.Error) { + collection, found := k.GetCollection(ctx, denom) + if found { + collection, err = collection.AddNFT(nft) + if err != nil { + return err + } + } else { + collection = types.NewCollection(denom, types.NewNFTs(nft)) + } + k.SetCollection(ctx, denom, collection) + + ownerIDCollection, _ := k.GetOwnerByDenom(ctx, nft.GetOwner(), denom) + ownerIDCollection = ownerIDCollection.AddID(nft.GetID()) + k.SetOwnerByDenom(ctx, nft.GetOwner(), denom, ownerIDCollection.IDs) + return +} + +// DeleteNFT deletes an existing NFT from store +func (k Keeper) DeleteNFT(ctx sdk.Context, denom, id string) (err sdk.Error) { + collection, found := k.GetCollection(ctx, denom) + if !found { + return types.ErrUnknownCollection(types.DefaultCodespace, fmt.Sprintf("collection of %s doesn't exist", denom)) + } + nft, err := collection.GetNFT(id) + if err != nil { + return err + } + ownerIDCollection, found := k.GetOwnerByDenom(ctx, nft.GetOwner(), denom) + if !found { + return types.ErrUnknownCollection(types.DefaultCodespace, + fmt.Sprintf("id collection #%s doesn't exist for owner %s", denom, nft.GetOwner()), + ) + } + ownerIDCollection, err = ownerIDCollection.DeleteID(nft.GetID()) + if err != nil { + return err + } + k.SetOwnerByDenom(ctx, nft.GetOwner(), denom, ownerIDCollection.IDs) + + collection, err = collection.DeleteNFT(nft) + if err != nil { + return err + } + + k.SetCollection(ctx, denom, collection) + + return +} diff --git a/x/nft/internal/keeper/nft_test.go b/x/nft/internal/keeper/nft_test.go new file mode 100644 index 000000000000..315e27c56b83 --- /dev/null +++ b/x/nft/internal/keeper/nft_test.go @@ -0,0 +1,132 @@ +package keeper_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" +) + +func TestMintNFT(t *testing.T) { + app, ctx := createTestApp(false) + + // MintNFT shouldn't fail when collection does not exist + nft := types.NewBaseNFT(id, address, tokenURI) + err := app.NFTKeeper.MintNFT(ctx, denom, &nft) + require.NoError(t, err) + + // MintNFT shouldn't fail when collection exists + nft2 := types.NewBaseNFT(id2, address, tokenURI) + err = app.NFTKeeper.MintNFT(ctx, denom, &nft2) + require.NoError(t, err) +} + +func TestGetNFT(t *testing.T) { + app, ctx := createTestApp(false) + + // MintNFT shouldn't fail when collection does not exist + nft := types.NewBaseNFT(id, address, tokenURI) + err := app.NFTKeeper.MintNFT(ctx, denom, &nft) + require.NoError(t, err) + + // GetNFT should get the NFT + receivedNFT, err := app.NFTKeeper.GetNFT(ctx, denom, id) + require.NoError(t, err) + require.Equal(t, receivedNFT.GetID(), id) + require.True(t, receivedNFT.GetOwner().Equals(address)) + require.Equal(t, receivedNFT.GetTokenURI(), tokenURI) + + // MintNFT shouldn't fail when collection exists + nft2 := types.NewBaseNFT(id2, address, tokenURI) + err = app.NFTKeeper.MintNFT(ctx, denom, &nft2) + require.NoError(t, err) + + // GetNFT should get the NFT when collection exists + receivedNFT2, err := app.NFTKeeper.GetNFT(ctx, denom, id2) + require.NoError(t, err) + require.Equal(t, receivedNFT2.GetID(), id2) + require.True(t, receivedNFT2.GetOwner().Equals(address)) + require.Equal(t, receivedNFT2.GetTokenURI(), tokenURI) + +} + +func TestUpdateNFT(t *testing.T) { + app, ctx := createTestApp(false) + + nft := types.NewBaseNFT(id, address, tokenURI) + + // UpdateNFT should fail when NFT doesn't exists + err := app.NFTKeeper.UpdateNFT(ctx, denom, &nft) + require.Error(t, err) + + // MintNFT shouldn't fail when collection does not exist + err = app.NFTKeeper.MintNFT(ctx, denom, &nft) + require.NoError(t, err) + + nonnft := types.NewBaseNFT(id2, address, tokenURI) + // UpdateNFT should fail when NFT doesn't exists + err = app.NFTKeeper.UpdateNFT(ctx, denom, &nonnft) + require.Error(t, err) + + // UpdateNFT shouldn't fail when NFT exists + nft2 := types.NewBaseNFT(id, address, tokenURI2) + err = app.NFTKeeper.UpdateNFT(ctx, denom, &nft2) + require.NoError(t, err) + + // UpdateNFT shouldn't fail when NFT exists + nft2 = types.NewBaseNFT(id, address2, tokenURI2) + err = app.NFTKeeper.UpdateNFT(ctx, denom, &nft2) + require.NoError(t, err) + + // GetNFT should get the NFT with new tokenURI + receivedNFT, err := app.NFTKeeper.GetNFT(ctx, denom, id) + require.NoError(t, err) + require.Equal(t, receivedNFT.GetTokenURI(), tokenURI2) + +} + +func TestDeleteNFT(t *testing.T) { + app, ctx := createTestApp(false) + + // DeleteNFT should fail when NFT doesn't exist and collection doesn't exist + err := app.NFTKeeper.DeleteNFT(ctx, denom, id) + require.Error(t, err) + + // MintNFT should not fail when collection does not exist + nft := types.NewBaseNFT(id, address, tokenURI) + err = app.NFTKeeper.MintNFT(ctx, denom, &nft) + require.NoError(t, err) + + // DeleteNFT should fail when NFT doesn't exist but collection does exist + err = app.NFTKeeper.DeleteNFT(ctx, denom, id2) + require.Error(t, err) + + // DeleteNFT should not fail when NFT and collection exist + err = app.NFTKeeper.DeleteNFT(ctx, denom, id) + require.NoError(t, err) + + // NFT should no longer exist + isNFT := app.NFTKeeper.IsNFT(ctx, denom, id) + require.False(t, isNFT) + + owner := app.NFTKeeper.GetOwner(ctx, address) + require.Equal(t, 0, owner.Supply()) +} + +func TestIsNFT(t *testing.T) { + app, ctx := createTestApp(false) + + // IsNFT should return false + isNFT := app.NFTKeeper.IsNFT(ctx, denom, id) + require.False(t, isNFT) + + // MintNFT shouldn't fail when collection does not exist + nft := types.NewBaseNFT(id, address, tokenURI) + err := app.NFTKeeper.MintNFT(ctx, denom, &nft) + require.NoError(t, err) + + // IsNFT should return true + isNFT = app.NFTKeeper.IsNFT(ctx, denom, id) + require.True(t, isNFT) +} diff --git a/x/nft/internal/keeper/owners.go b/x/nft/internal/keeper/owners.go new file mode 100644 index 000000000000..a2850b027540 --- /dev/null +++ b/x/nft/internal/keeper/owners.go @@ -0,0 +1,132 @@ +package keeper + +import ( + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" +) + +// GetOwners returns all the Owners ID Collections +func (k Keeper) GetOwners(ctx sdk.Context) (owners []types.Owner) { + var foundOwners = make(map[string]bool) + k.IterateOwners(ctx, + func(owner types.Owner) (stop bool) { + if _, ok := foundOwners[owner.Address.String()]; !ok { + foundOwners[owner.Address.String()] = true + owners = append(owners, owner) + } + return false + }, + ) + return +} + +// GetOwner gets all the ID Collections owned by an address +func (k Keeper) GetOwner(ctx sdk.Context, address sdk.AccAddress) (owner types.Owner) { + var idCollections []types.IDCollection + k.IterateIDCollections(ctx, types.GetOwnersKey(address), + func(_ sdk.AccAddress, idCollection types.IDCollection) (stop bool) { + idCollections = append(idCollections, idCollection) + return false + }, + ) + return types.NewOwner(address, idCollections...) +} + +// GetOwnerByDenom gets the ID Collection owned by an address of a specific denom +func (k Keeper) GetOwnerByDenom(ctx sdk.Context, owner sdk.AccAddress, denom string) (idCollection types.IDCollection, found bool) { + + store := ctx.KVStore(k.storeKey) + b := store.Get(types.GetOwnerKey(owner, denom)) + if b == nil { + return types.NewIDCollection(denom, []string{}), false + } + k.cdc.MustUnmarshalBinaryLengthPrefixed(b, &idCollection) + return idCollection, true +} + +// SetOwnerByDenom sets a collection of NFT IDs owned by an address +func (k Keeper) SetOwnerByDenom(ctx sdk.Context, owner sdk.AccAddress, denom string, ids []string) { + store := ctx.KVStore(k.storeKey) + key := types.GetOwnerKey(owner, denom) + + var idCollection types.IDCollection + idCollection.Denom = denom + idCollection.IDs = ids + + store.Set(key, k.cdc.MustMarshalBinaryLengthPrefixed(idCollection)) +} + +// SetOwner sets an entire Owner +func (k Keeper) SetOwner(ctx sdk.Context, owner types.Owner) { + for _, idCollection := range owner.IDCollections { + k.SetOwnerByDenom(ctx, owner.Address, idCollection.Denom, idCollection.IDs) + } +} + +// SetOwners sets all Owners +func (k Keeper) SetOwners(ctx sdk.Context, owners []types.Owner) { + for _, owner := range owners { + k.SetOwner(ctx, owner) + } +} + +// IterateIDCollections iterates over the IDCollections by Owner and performs a function +func (k Keeper) IterateIDCollections(ctx sdk.Context, prefix []byte, + handler func(owner sdk.AccAddress, idCollection types.IDCollection) (stop bool)) { + + store := ctx.KVStore(k.storeKey) + iterator := sdk.KVStorePrefixIterator(store, prefix) + defer iterator.Close() + for ; iterator.Valid(); iterator.Next() { + + var idCollection types.IDCollection + k.cdc.MustUnmarshalBinaryLengthPrefixed(iterator.Value(), &idCollection) + + owner, _ := types.SplitOwnerKey(iterator.Key()) + if handler(owner, idCollection) { + break + } + } +} + +// IterateOwners iterates over all Owners and performs a function +func (k Keeper) IterateOwners(ctx sdk.Context, handler func(owner types.Owner) (stop bool)) { + store := ctx.KVStore(k.storeKey) + iterator := sdk.KVStorePrefixIterator(store, types.OwnersKeyPrefix) + defer iterator.Close() + for ; iterator.Valid(); iterator.Next() { + var owner types.Owner + + address, _ := types.SplitOwnerKey(iterator.Key()) + owner = k.GetOwner(ctx, address) + + if handler(owner) { + break + } + } +} + +// SwapOwners swaps the owners of a NFT ID +func (k Keeper) SwapOwners(ctx sdk.Context, denom string, id string, oldAddress sdk.AccAddress, newAddress sdk.AccAddress) (err sdk.Error) { + oldOwnerIDCollection, found := k.GetOwnerByDenom(ctx, oldAddress, denom) + if !found { + return types.ErrUnknownCollection(types.DefaultCodespace, + fmt.Sprintf("id collection %s doesn't exist for owner %s", denom, oldAddress), + ) + } + oldOwnerIDCollection, err = oldOwnerIDCollection.DeleteID(id) + if err != nil { + return err + } + k.SetOwnerByDenom(ctx, oldAddress, denom, oldOwnerIDCollection.IDs) + + newOwnerIDCollection, found := k.GetOwnerByDenom(ctx, newAddress, denom) + if !found { + newOwnerIDCollection = types.NewIDCollection(denom, []string{}) + } + newOwnerIDCollection = newOwnerIDCollection.AddID(id) + k.SetOwnerByDenom(ctx, newAddress, denom, newOwnerIDCollection.IDs) + return nil +} diff --git a/x/nft/internal/keeper/owners_test.go b/x/nft/internal/keeper/owners_test.go new file mode 100644 index 000000000000..caf203a05235 --- /dev/null +++ b/x/nft/internal/keeper/owners_test.go @@ -0,0 +1,108 @@ +package keeper_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" +) + +func TestGetOwners(t *testing.T) { + app, ctx := createTestApp(false) + + nft := types.NewBaseNFT(id, address, tokenURI) + err := app.NFTKeeper.MintNFT(ctx, denom, &nft) + require.NoError(t, err) + + nft2 := types.NewBaseNFT(id2, address2, tokenURI) + err = app.NFTKeeper.MintNFT(ctx, denom, &nft2) + require.NoError(t, err) + + nft3 := types.NewBaseNFT(id3, address3, tokenURI) + err = app.NFTKeeper.MintNFT(ctx, denom, &nft3) + require.NoError(t, err) + + owners := app.NFTKeeper.GetOwners(ctx) + require.Equal(t, 3, len(owners)) + + nft = types.NewBaseNFT(id, address, tokenURI) + err = app.NFTKeeper.MintNFT(ctx, denom2, &nft) + require.NoError(t, err) + + nft2 = types.NewBaseNFT(id2, address2, tokenURI) + err = app.NFTKeeper.MintNFT(ctx, denom2, &nft2) + require.NoError(t, err) + + nft3 = types.NewBaseNFT(id3, address3, tokenURI) + err = app.NFTKeeper.MintNFT(ctx, denom2, &nft3) + require.NoError(t, err) + + owners = app.NFTKeeper.GetOwners(ctx) + require.Equal(t, 3, len(owners)) +} + +func TestSetOwner(t *testing.T) { + app, ctx := createTestApp(false) + + nft := types.NewBaseNFT(id, address, tokenURI) + err := app.NFTKeeper.MintNFT(ctx, denom, &nft) + require.NoError(t, err) + + idCollection := types.NewIDCollection(denom, []string{id, id2, id3}) + owner := types.NewOwner(address, idCollection) + + oldOwner := app.NFTKeeper.GetOwner(ctx, address) + + app.NFTKeeper.SetOwner(ctx, owner) + + newOwner := app.NFTKeeper.GetOwner(ctx, address) + require.NotEqual(t, oldOwner.String(), newOwner.String()) + require.Equal(t, owner.String(), newOwner.String()) +} + +func TestSetOwners(t *testing.T) { + app, ctx := createTestApp(false) + + nft := types.NewBaseNFT(id, address, tokenURI) + err := app.NFTKeeper.MintNFT(ctx, denom, &nft) + require.NoError(t, err) + + nft = types.NewBaseNFT(id2, address2, tokenURI) + err = app.NFTKeeper.MintNFT(ctx, denom, &nft) + require.NoError(t, err) + + idCollection := types.NewIDCollection(denom, []string{id, id2, id3}) + owner := types.NewOwner(address, idCollection) + owner2 := types.NewOwner(address2, idCollection) + + oldOwner := app.NFTKeeper.GetOwner(ctx, address) + oldOwner2 := app.NFTKeeper.GetOwner(ctx, address2) + + app.NFTKeeper.SetOwners(ctx, []types.Owner{owner, owner2}) + + newOwner := app.NFTKeeper.GetOwner(ctx, address) + require.NotEqual(t, oldOwner.String(), newOwner.String()) + require.Equal(t, owner.String(), newOwner.String()) + + newOwner2 := app.NFTKeeper.GetOwner(ctx, address2) + require.NotEqual(t, oldOwner2.String(), newOwner2.String()) + require.Equal(t, owner2.String(), newOwner2.String()) +} + +func TestSwapOwners(t *testing.T) { + app, ctx := createTestApp(false) + + nft := types.NewBaseNFT(id, address, tokenURI) + err := app.NFTKeeper.MintNFT(ctx, denom, &nft) + require.NoError(t, err) + + err = app.NFTKeeper.SwapOwners(ctx, denom, id, address, address2) + require.NoError(t, err) + + err = app.NFTKeeper.SwapOwners(ctx, denom, id, address, address2) + require.Error(t, err) + + err = app.NFTKeeper.SwapOwners(ctx, denom2, id, address, address2) + require.Error(t, err) +} diff --git a/x/nft/internal/keeper/querier.go b/x/nft/internal/keeper/querier.go new file mode 100644 index 000000000000..2a08782f6463 --- /dev/null +++ b/x/nft/internal/keeper/querier.go @@ -0,0 +1,155 @@ +package keeper + +import ( + "encoding/binary" + "fmt" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" + + abci "github.com/tendermint/tendermint/abci/types" +) + +// query endpoints supported by the NFT Querier +const ( + QuerySupply = "supply" + QueryOwner = "owner" + QueryOwnerByDenom = "ownerByDenom" + QueryCollection = "collection" + QueryDenoms = "denoms" + QueryNFT = "nft" +) + +// NewQuerier is the module level router for state queries +func NewQuerier(k Keeper) sdk.Querier { + return func(ctx sdk.Context, path []string, req abci.RequestQuery) (res []byte, err sdk.Error) { + switch path[0] { + case QuerySupply: + return querySupply(ctx, path[1:], req, k) + case QueryOwner: + return queryOwner(ctx, path[1:], req, k) + case QueryOwnerByDenom: + return queryOwnerByDenom(ctx, path[1:], req, k) + case QueryCollection: + return queryCollection(ctx, path[1:], req, k) + case QueryDenoms: + return queryDenoms(ctx, path[1:], req, k) + case QueryNFT: + return queryNFT(ctx, path[1:], req, k) + default: + return nil, sdk.ErrUnknownRequest("unknown nft query endpoint") + } + } +} + +func querySupply(ctx sdk.Context, path []string, req abci.RequestQuery, k Keeper) ([]byte, sdk.Error) { + var params types.QueryCollectionParams + + err := types.ModuleCdc.UnmarshalJSON(req.Data, ¶ms) + if err != nil { + return nil, sdk.ErrUnknownRequest(sdk.AppendMsgToErr("incorrectly formatted request data", err.Error())) + } + + collection, found := k.GetCollection(ctx, params.Denom) + if !found { + return nil, types.ErrUnknownCollection(types.DefaultCodespace, fmt.Sprintf("unknown denom %s", params.Denom)) + } + + bz := make([]byte, 8) + binary.LittleEndian.PutUint64(bz, uint64(collection.Supply())) + return bz, nil +} + +func queryOwner(ctx sdk.Context, path []string, req abci.RequestQuery, k Keeper) ([]byte, sdk.Error) { + var params types.QueryBalanceParams + + err := types.ModuleCdc.UnmarshalJSON(req.Data, ¶ms) + if err != nil { + return nil, sdk.ErrUnknownRequest(sdk.AppendMsgToErr("incorrectly formatted request data", err.Error())) + } + + owner := k.GetOwner(ctx, params.Owner) + bz, err := types.ModuleCdc.MarshalJSON(owner) + if err != nil { + return nil, sdk.ErrInternal(sdk.AppendMsgToErr("failed to JSON marshal result: %s", err.Error())) + } + + return bz, nil +} + +func queryOwnerByDenom(ctx sdk.Context, path []string, req abci.RequestQuery, k Keeper) ([]byte, sdk.Error) { + var params types.QueryBalanceParams + + err := types.ModuleCdc.UnmarshalJSON(req.Data, ¶ms) + if err != nil { + return nil, sdk.ErrUnknownRequest(sdk.AppendMsgToErr("incorrectly formatted request data", err.Error())) + } + + var owner types.Owner + + idCollection, _ := k.GetOwnerByDenom(ctx, params.Owner, params.Denom) + owner.Address = params.Owner + owner.IDCollections = append(owner.IDCollections, idCollection) + + bz, err := types.ModuleCdc.MarshalJSON(owner) + if err != nil { + return nil, sdk.ErrInternal(sdk.AppendMsgToErr("failed to JSON marshal result: %s", err.Error())) + } + + return bz, nil +} + +func queryCollection(ctx sdk.Context, path []string, req abci.RequestQuery, k Keeper) ([]byte, sdk.Error) { + var params types.QueryCollectionParams + + err := types.ModuleCdc.UnmarshalJSON(req.Data, ¶ms) + if err != nil { + return nil, sdk.ErrUnknownRequest(sdk.AppendMsgToErr("incorrectly formatted request data", err.Error())) + } + + collection, found := k.GetCollection(ctx, params.Denom) + if !found { + return nil, types.ErrUnknownCollection(types.DefaultCodespace, fmt.Sprintf("unknown denom %s", params.Denom)) + } + + // use Collections custom JSON to make the denom the key of the object + collections := types.NewCollections(collection) + bz, err := types.ModuleCdc.MarshalJSON(collections) + if err != nil { + return nil, sdk.ErrInternal(sdk.AppendMsgToErr("failed to JSON marshal result: %s", err.Error())) + } + + return bz, nil +} + +func queryDenoms(ctx sdk.Context, path []string, req abci.RequestQuery, k Keeper) ([]byte, sdk.Error) { + denoms := k.GetDenoms(ctx) + + bz, err := types.ModuleCdc.MarshalJSON(denoms) + if err != nil { + return nil, sdk.ErrInternal(sdk.AppendMsgToErr("failed to JSON marshal result: %s", err.Error())) + } + + return bz, nil +} + +func queryNFT(ctx sdk.Context, path []string, req abci.RequestQuery, k Keeper) ([]byte, sdk.Error) { + var params types.QueryNFTParams + + err := types.ModuleCdc.UnmarshalJSON(req.Data, ¶ms) + if err != nil { + return nil, sdk.ErrUnknownRequest(sdk.AppendMsgToErr("incorrectly formatted request data", err.Error())) + } + + nft, err := k.GetNFT(ctx, params.Denom, params.TokenID) + if err != nil { + return nil, types.ErrUnknownNFT(types.DefaultCodespace, fmt.Sprintf("invalid NFT #%s from collection %s", params.TokenID, params.Denom)) + } + + bz, err := types.ModuleCdc.MarshalJSON(nft) + if err != nil { + return nil, sdk.ErrInternal(sdk.AppendMsgToErr("failed to JSON marshal result: %s", err.Error())) + } + + return bz, nil +} diff --git a/x/nft/internal/keeper/querier_test.go b/x/nft/internal/keeper/querier_test.go new file mode 100644 index 000000000000..ebe51144ac65 --- /dev/null +++ b/x/nft/internal/keeper/querier_test.go @@ -0,0 +1,261 @@ +package keeper_test + +import ( + "encoding/binary" + "testing" + + "github.com/stretchr/testify/require" + abci "github.com/tendermint/tendermint/abci/types" + + "github.com/cosmos/cosmos-sdk/x/nft/exported" + keep "github.com/cosmos/cosmos-sdk/x/nft/internal/keeper" + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" +) + +func TestNewQuerier(t *testing.T) { + app, ctx := createTestApp(false) + querier := keep.NewQuerier(app.NFTKeeper) + query := abci.RequestQuery{ + Path: "", + Data: []byte{}, + } + _, err := querier(ctx, []string{"foo", "bar"}, query) + require.Error(t, err) +} + +func TestQuerySupply(t *testing.T) { + app, ctx := createTestApp(false) + + // MintNFT shouldn't fail when collection does not exist + nft := types.NewBaseNFT(id, address, tokenURI) + err := app.NFTKeeper.MintNFT(ctx, denom, &nft) + require.NoError(t, err) + + querier := keep.NewQuerier(app.NFTKeeper) + + query := abci.RequestQuery{ + Path: "", + Data: []byte{}, + } + + query.Path = "/custom/nft/supply" + query.Data = []byte("?") + + res, err := querier(ctx, []string{"supply"}, query) + require.Error(t, err) + require.Nil(t, res) + + queryCollectionParams := types.NewQueryCollectionParams(denom2) + bz, errRes := app.Codec().MarshalJSON(queryCollectionParams) + require.Nil(t, errRes) + query.Data = bz + res, err = querier(ctx, []string{"supply"}, query) + require.Error(t, err) + require.Nil(t, res) + + queryCollectionParams = types.NewQueryCollectionParams(denom) + bz, errRes = app.Codec().MarshalJSON(queryCollectionParams) + require.Nil(t, errRes) + query.Data = bz + + res, err = querier(ctx, []string{"supply"}, query) + require.NoError(t, err) + require.NotNil(t, res) + + supplyResp := binary.LittleEndian.Uint64(res) + require.Equal(t, 1, int(supplyResp)) +} + +func TestQueryCollection(t *testing.T) { + app, ctx := createTestApp(false) + + // MintNFT shouldn't fail when collection does not exist + nft := types.NewBaseNFT(id, address, tokenURI) + err := app.NFTKeeper.MintNFT(ctx, denom, &nft) + require.NoError(t, err) + + querier := keep.NewQuerier(app.NFTKeeper) + + query := abci.RequestQuery{ + Path: "", + Data: []byte{}, + } + + query.Path = "/custom/nft/collection" + + query.Data = []byte("?") + res, err := querier(ctx, []string{"collection"}, query) + require.Error(t, err) + require.Nil(t, res) + + queryCollectionParams := types.NewQueryCollectionParams(denom2) + bz, errRes := app.Codec().MarshalJSON(queryCollectionParams) + require.Nil(t, errRes) + + query.Data = bz + res, err = querier(ctx, []string{"collection"}, query) + require.Error(t, err) + require.Nil(t, res) + + queryCollectionParams = types.NewQueryCollectionParams(denom) + bz, errRes = app.Codec().MarshalJSON(queryCollectionParams) + require.Nil(t, errRes) + + query.Data = bz + res, err = querier(ctx, []string{"collection"}, query) + require.NoError(t, err) + require.NotNil(t, res) + + var collections types.Collections + types.ModuleCdc.MustUnmarshalJSON(res, &collections) + require.Len(t, collections, 1) + require.Len(t, collections[0].NFTs, 1) +} + +func TestQueryOwner(t *testing.T) { + app, ctx := createTestApp(false) + + // MintNFT shouldn't fail when collection does not exist + nft := types.NewBaseNFT(id, address, tokenURI) + err := app.NFTKeeper.MintNFT(ctx, denom, &nft) + require.NoError(t, err) + + denom2 := "test_denom2" + err = app.NFTKeeper.MintNFT(ctx, denom2, &nft) + require.NoError(t, err) + + querier := keep.NewQuerier(app.NFTKeeper) + + query := abci.RequestQuery{ + Path: "", + Data: []byte{}, + } + query.Path = "/custom/nft/ownerByDenom" + + query.Data = []byte("?") + res, err := querier(ctx, []string{"ownerByDenom"}, query) + require.Error(t, err) + require.Nil(t, res) + + // query the balance using the first denom + params := types.NewQueryBalanceParams(address, denom) + bz, err2 := app.Codec().MarshalJSON(params) + require.Nil(t, err2) + query.Data = bz + + res, err = querier(ctx, []string{"ownerByDenom"}, query) + require.NoError(t, err) + require.NotNil(t, res) + + var out types.Owner + app.Codec().MustUnmarshalJSON(res, &out) + + // build the owner using only the first denom + idCollection1 := types.NewIDCollection(denom, []string{id}) + owner := types.NewOwner(address, idCollection1) + + require.Equal(t, out.String(), owner.String()) + + // query the balance using no denom so that all denoms will be returns + params = types.NewQueryBalanceParams(address, "") + bz, err2 = app.Codec().MarshalJSON(params) + require.Nil(t, err2) + + query.Path = "/custom/nft/owner" + query.Data = []byte("?") + _, err = querier(ctx, []string{"owner"}, query) + require.Error(t, err) + + query.Data = bz + res, err = querier(ctx, []string{"owner"}, query) + require.NoError(t, err) + require.NotNil(t, res) + + app.Codec().MustUnmarshalJSON(res, &out) + + // build the owner using both denoms TODO: add sorting to ensure the objects are the same + idCollection2 := types.NewIDCollection(denom2, []string{id}) + owner = types.NewOwner(address, idCollection2, idCollection1) + + require.Equal(t, out.String(), owner.String()) +} + +func TestQueryNFT(t *testing.T) { + app, ctx := createTestApp(false) + + // MintNFT shouldn't fail when collection does not exist + nft := types.NewBaseNFT(id, address, tokenURI) + err := app.NFTKeeper.MintNFT(ctx, denom, &nft) + require.NoError(t, err) + + querier := keep.NewQuerier(app.NFTKeeper) + + query := abci.RequestQuery{ + Path: "", + Data: []byte{}, + } + query.Path = "/custom/nft/nft" + var res []byte + + query.Data = []byte("?") + res, err = querier(ctx, []string{"nft"}, query) + require.Error(t, err) + require.Nil(t, res) + + params := types.NewQueryNFTParams(denom2, id2) + bz, err2 := app.Codec().MarshalJSON(params) + require.Nil(t, err2) + + query.Data = bz + res, err = querier(ctx, []string{"nft"}, query) + require.Error(t, err) + require.Nil(t, res) + + params = types.NewQueryNFTParams(denom, id) + bz, err2 = app.Codec().MarshalJSON(params) + require.Nil(t, err2) + + query.Data = bz + res, err = querier(ctx, []string{"nft"}, query) + require.NoError(t, err) + require.NotNil(t, res) + + var out exported.NFT + app.Codec().MustUnmarshalJSON(res, &out) + + require.Equal(t, out.String(), nft.String()) +} + +func TestQueryDenoms(t *testing.T) { + app, ctx := createTestApp(false) + + // MintNFT shouldn't fail when collection does not exist + nft := types.NewBaseNFT(id, address, tokenURI) + err := app.NFTKeeper.MintNFT(ctx, denom, &nft) + require.NoError(t, err) + + err = app.NFTKeeper.MintNFT(ctx, denom2, &nft) + require.NoError(t, err) + + querier := keep.NewQuerier(app.NFTKeeper) + + query := abci.RequestQuery{ + Path: "", + Data: []byte{}, + } + var res []byte + query.Path = "/custom/nft/denoms" + + res, err = querier(ctx, []string{"denoms"}, query) + require.NoError(t, err) + require.NotNil(t, res) + + denoms := []string{denom, denom2} + + var out []string + app.Codec().MustUnmarshalJSON(res, &out) + + for key, denomInQuestion := range out { + require.Equal(t, denomInQuestion, denoms[key]) + } +} diff --git a/x/nft/internal/types/codec.go b/x/nft/internal/types/codec.go new file mode 100644 index 000000000000..c9a1d1ea3d04 --- /dev/null +++ b/x/nft/internal/types/codec.go @@ -0,0 +1,31 @@ +package types + +// DONTCOVER + +import ( + "github.com/cosmos/cosmos-sdk/codec" + "github.com/cosmos/cosmos-sdk/x/nft/exported" +) + +// RegisterCodec concrete types on codec +func RegisterCodec(cdc *codec.Codec) { + cdc.RegisterInterface((*exported.NFT)(nil), nil) + cdc.RegisterConcrete(&BaseNFT{}, "cosmos-sdk/BaseNFT", nil) + cdc.RegisterConcrete(&IDCollection{}, "cosmos-sdk/IDCollection", nil) + cdc.RegisterConcrete(&Collection{}, "cosmos-sdk/Collection", nil) + cdc.RegisterConcrete(&Owner{}, "cosmos-sdk/Owner", nil) + cdc.RegisterConcrete(MsgTransferNFT{}, "cosmos-sdk/MsgTransferNFT", nil) + cdc.RegisterConcrete(MsgEditNFTMetadata{}, "cosmos-sdk/MsgEditNFTMetadata", nil) + cdc.RegisterConcrete(MsgMintNFT{}, "cosmos-sdk/MsgMintNFT", nil) + cdc.RegisterConcrete(MsgBurnNFT{}, "cosmos-sdk/MsgBurnNFT", nil) +} + +// ModuleCdc generic sealed codec to be used throughout this module +var ModuleCdc *codec.Codec + +func init() { + ModuleCdc = codec.New() + codec.RegisterCrypto(ModuleCdc) + RegisterCodec(ModuleCdc) + ModuleCdc.Seal() +} diff --git a/x/nft/internal/types/collection.go b/x/nft/internal/types/collection.go new file mode 100644 index 000000000000..a93c80e5e29d --- /dev/null +++ b/x/nft/internal/types/collection.go @@ -0,0 +1,232 @@ +package types + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/nft/exported" +) + +// Collection of non fungible tokens +type Collection struct { + Denom string `json:"denom,omitempty" yaml:"denom"` // name of the collection; not exported to clients + NFTs NFTs `json:"nfts" yaml:"nfts"` // NFTs that belong to a collection +} + +// NewCollection creates a new NFT Collection +func NewCollection(denom string, nfts NFTs) Collection { + return Collection{ + Denom: strings.TrimSpace(denom), + NFTs: nfts, + } +} + +// EmptyCollection returns an empty collection +func EmptyCollection() Collection { + return NewCollection("", NewNFTs()) +} + +// GetNFT gets a NFT from the collection +func (collection Collection) GetNFT(id string) (nft exported.NFT, err sdk.Error) { + for _, nft := range collection.NFTs { + if nft.GetID() == id { + return nft, nil + } + } + return nil, ErrUnknownNFT(DefaultCodespace, + fmt.Sprintf("NFT #%s doesn't exist in collection %s", id, collection.Denom), + ) +} + +// ContainsNFT returns whether or not a Collection contains an NFT +func (collection Collection) ContainsNFT(id string) bool { + _, err := collection.GetNFT(id) + return err == nil +} + +// AddNFT adds an NFT to the collection +func (collection Collection) AddNFT(nft exported.NFT) (Collection, sdk.Error) { + id := nft.GetID() + exists := collection.ContainsNFT(id) + if exists { + return collection, ErrNFTAlreadyExists(DefaultCodespace, + fmt.Sprintf("NFT #%s already exists in collection %s", id, collection.Denom), + ) + } + collection.NFTs = append(collection.NFTs, nft) + return collection, nil +} + +// UpdateNFT updates an NFT from a collection +func (collection Collection) UpdateNFT(nft exported.NFT) (Collection, sdk.Error) { + nfts, ok := collection.NFTs.Update(nft.GetID(), nft) + + if !ok { + return collection, ErrUnknownNFT(DefaultCodespace, + fmt.Sprintf("NFT #%s doesn't exist on collection %s", nft.GetID(), collection.Denom), + ) + } + collection.NFTs = nfts + return collection, nil +} + +// DeleteNFT deletes an NFT from a collection +func (collection Collection) DeleteNFT(nft exported.NFT) (Collection, sdk.Error) { + nfts, ok := collection.NFTs.Remove(nft.GetID()) + if !ok { + return collection, ErrUnknownNFT(DefaultCodespace, + fmt.Sprintf("NFT #%s doesn't exist on collection %s", nft.GetID(), collection.Denom), + ) + } + collection.NFTs = nfts + return collection, nil +} + +// Supply gets the total supply of NFTs of a collection +func (collection Collection) Supply() int { + return len(collection.NFTs) +} + +// String follows stringer interface +func (collection Collection) String() string { + return fmt.Sprintf(`Denom: %s +NFTs: + +%s`, + collection.Denom, + collection.NFTs.String(), + ) +} + +// ---------------------------------------------------------------------------- +// Collections + +// Collections define an array of Collection +type Collections []Collection + +// NewCollections creates a new set of NFTs +func NewCollections(collections ...Collection) Collections { + if len(collections) == 0 { + return Collections{} + } + return Collections(collections) +} + +// Add appends two sets of Collections +func (collections Collections) Add(collectionsB Collections) Collections { + return append(collections, collectionsB...) +} + +// Find returns the searched collection from the set +func (collections Collections) Find(denom string) (Collection, bool) { + index := collections.find(denom) + if index == -1 { + return Collection{}, false + } + return collections[index], true +} + +// Remove removes a collection from the set of collections +func (collections Collections) Remove(denom string) (Collections, bool) { + index := collections.find(denom) + if index == -1 { + return collections, false + } + collections[len(collections)-1], collections[index] = collections[index], collections[len(collections)-1] + return collections[:len(collections)-1], true +} + +// String follows stringer interface +func (collections Collections) String() string { + if len(collections) == 0 { + return "" + } + + out := "" + for _, collection := range collections { + out += fmt.Sprintf("%v\n", collection.String()) + } + return out[:len(out)-1] +} + +// Empty returns true if there are no collections and false otherwise. +func (collections Collections) Empty() bool { + return len(collections) == 0 +} + +func (collections Collections) find(denom string) (idx int) { + if len(collections) == 0 { + return -1 + } + // TODO: ensure this is already sorted + // collections.Sort() + + midIdx := len(collections) / 2 + midCollection := collections[midIdx] + + switch { + case strings.Compare(denom, midCollection.Denom) == -1: + return collections[:midIdx].find(denom) + case midCollection.Denom == denom: + return midIdx + default: + return collections[midIdx+1:].find(denom) + } +} + +// ---------------------------------------------------------------------------- +// Encoding + +// CollectionJSON is the exported Collection format for clients +type CollectionJSON map[string]Collection + +// MarshalJSON for Collections +func (collections Collections) MarshalJSON() ([]byte, error) { + collectionJSON := make(CollectionJSON) + + for _, collection := range collections { + denom := collection.Denom + collection.Denom = "" + collectionJSON[denom] = collection + } + + return json.Marshal(collectionJSON) +} + +// UnmarshalJSON for Collections +func (collections *Collections) UnmarshalJSON(b []byte) error { + collectionJSON := make(CollectionJSON) + + if err := json.Unmarshal(b, &collectionJSON); err != nil { + return err + } + + for denom, collection := range collectionJSON { + *collections = append(*collections, NewCollection(denom, collection.NFTs)) + } + + return nil +} + +//----------------------------------------------------------------------------- +// Sort interface + +//nolint +func (collections Collections) Len() int { return len(collections) } +func (collections Collections) Less(i, j int) bool { + return strings.Compare(collections[i].Denom, collections[j].Denom) == -1 +} +func (collections Collections) Swap(i, j int) { + collections[i], collections[j] = collections[j], collections[i] +} + +var _ sort.Interface = Collections{} + +// Sort is a helper function to sort the set of coins inplace +func (collections Collections) Sort() Collections { + sort.Sort(collections) + return collections +} diff --git a/x/nft/internal/types/collection_test.go b/x/nft/internal/types/collection_test.go new file mode 100644 index 000000000000..9551a4b99927 --- /dev/null +++ b/x/nft/internal/types/collection_test.go @@ -0,0 +1,347 @@ +package types + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +// ---------------------------------------- Collection --------------------------------------------------- + +func TestNewCollection(t *testing.T) { + testNFT := NewBaseNFT(id, address, tokenURI) + testNFT2 := NewBaseNFT(id2, address, tokenURI) + nfts := NewNFTs(&testNFT, &testNFT2) + collection := NewCollection(fmt.Sprintf(" %s ", denom), nfts) + require.Equal(t, collection.Denom, denom) + require.Equal(t, len(collection.NFTs), 2) +} + +func TestEmptyCollection(t *testing.T) { + collection := EmptyCollection() + require.Equal(t, collection.Denom, "") + require.Equal(t, len(collection.NFTs), 0) +} + +func TestCollectionGetNFTMethod(t *testing.T) { + testNFT := NewBaseNFT(id, address, tokenURI) + nfts := NewNFTs(&testNFT) + collection := NewCollection(denom, nfts) + + returnedNFT, err := collection.GetNFT(id) + require.NoError(t, err) + require.Equal(t, testNFT.String(), returnedNFT.String()) + + returnedNFT, err = collection.GetNFT(id2) + require.Error(t, err) + require.Nil(t, returnedNFT) +} + +func TestCollectionContainsNFTMethod(t *testing.T) { + testNFT := NewBaseNFT(id, address, tokenURI) + nfts := NewNFTs(&testNFT) + collection := NewCollection(denom, nfts) + + contains := collection.ContainsNFT(id) + require.True(t, contains) + + contains = collection.ContainsNFT(id2) + require.False(t, contains) +} + +func TestCollectionAddNFTMethod(t *testing.T) { + testNFT := NewBaseNFT(id, address, tokenURI) + testNFT2 := NewBaseNFT(id2, address, tokenURI) + nfts := NewNFTs(&testNFT) + collection := NewCollection(denom, nfts) + + newCollection, err := collection.AddNFT(&testNFT) + require.Error(t, err) + require.Equal(t, collection.String(), newCollection.String()) + + newCollection, err = collection.AddNFT(&testNFT2) + require.NoError(t, err) + require.NotEqual(t, collection.String(), newCollection.String()) + require.Equal(t, len(newCollection.NFTs), 2) + +} + +func TestCollectionUpdateNFTMethod(t *testing.T) { + testNFT := NewBaseNFT(id, address, tokenURI) + testNFT2 := NewBaseNFT(id2, address2, tokenURI2) + testNFT3 := NewBaseNFT(id, address2, tokenURI2) + nfts := NewNFTs(&testNFT) + collection := NewCollection(denom, nfts) + + newCollection, err := collection.UpdateNFT(&testNFT2) + require.Error(t, err) + require.Equal(t, collection.String(), newCollection.String()) + + collection, err = collection.UpdateNFT(&testNFT3) + require.NoError(t, err) + + returnedNFT, err := collection.GetNFT(id) + require.NoError(t, err) + + require.Equal(t, returnedNFT.GetOwner(), address2) + require.Equal(t, returnedNFT.GetTokenURI(), tokenURI2) + +} + +func TestCollectionDeleteNFTMethod(t *testing.T) { + testNFT := NewBaseNFT(id, address, tokenURI) + testNFT2 := NewBaseNFT(id2, address2, tokenURI2) + testNFT3 := NewBaseNFT(id3, address, tokenURI) + nfts := NewNFTs(&testNFT, &testNFT2) + collection := NewCollection(denom, nfts) + + newCollection, err := collection.DeleteNFT(&testNFT3) + require.Error(t, err) + require.Equal(t, collection.String(), newCollection.String()) + + collection, err = collection.DeleteNFT(&testNFT2) + require.NoError(t, err) + require.Equal(t, len(collection.NFTs), 1) + + returnedNFT, err := collection.GetNFT(id2) + require.Nil(t, returnedNFT) + require.Error(t, err) +} + +func TestCollectionSupplyMethod(t *testing.T) { + + empty := EmptyCollection() + require.Equal(t, empty.Supply(), 0) + + testNFT := NewBaseNFT(id, address, tokenURI) + testNFT2 := NewBaseNFT(id2, address2, tokenURI2) + nfts := NewNFTs(&testNFT, &testNFT2) + collection := NewCollection(denom, nfts) + + require.Equal(t, collection.Supply(), 2) + + collection, err := collection.DeleteNFT(&testNFT) + require.Nil(t, err) + require.Equal(t, collection.Supply(), 1) + + collection, err = collection.DeleteNFT(&testNFT2) + require.Nil(t, err) + require.Equal(t, collection.Supply(), 0) + + collection, err = collection.AddNFT(&testNFT) + require.Nil(t, err) + require.Equal(t, collection.Supply(), 1) + +} + +func TestCollectionStringMethod(t *testing.T) { + testNFT := NewBaseNFT(id, address, tokenURI) + testNFT2 := NewBaseNFT(id2, address2, tokenURI2) + nfts := NewNFTs(&testNFT, &testNFT2) + collection := NewCollection(denom, nfts) + require.Equal(t, collection.String(), + fmt.Sprintf(`Denom: %s +NFTs: + +ID: %s +Owner: %s +TokenURI: %s +ID: %s +Owner: %s +TokenURI: %s`, denom, id, address.String(), tokenURI, + id2, address2.String(), tokenURI2)) +} + +// ---------------------------------------- Collections --------------------------------------------------- + +func TestNewCollections(t *testing.T) { + + emptyCollections := NewCollections() + require.Empty(t, emptyCollections) + + testNFT := NewBaseNFT(id, address, tokenURI) + nfts := NewNFTs(&testNFT) + collection := NewCollection(denom, nfts) + + testNFT2 := NewBaseNFT(id2, address2, tokenURI2) + nfts2 := NewNFTs(&testNFT2) + collection2 := NewCollection(denom2, nfts2) + + collections := NewCollections(collection, collection2) + require.Equal(t, len(collections), 2) + +} + +func TestCollectionsAddMethod(t *testing.T) { + + testNFT := NewBaseNFT(id, address, tokenURI) + nfts := NewNFTs(&testNFT) + collection := NewCollection(denom, nfts) + + collections := NewCollections(collection) + + testNFT2 := NewBaseNFT(id2, address2, tokenURI2) + nfts2 := NewNFTs(&testNFT2) + collection2 := NewCollection(denom2, nfts2) + collections2 := NewCollections(collection2) + + collections = collections.Add(collections2) + require.Equal(t, len(collections), 2) + +} +func TestCollectionsFindMethod(t *testing.T) { + + testNFT := NewBaseNFT(id, address, tokenURI) + nfts := NewNFTs(&testNFT) + collection := NewCollection(denom, nfts) + + testNFT2 := NewBaseNFT(id2, address2, tokenURI2) + nfts2 := NewNFTs(&testNFT2) + collection2 := NewCollection(denom2, nfts2) + + collections := NewCollections(collection) + + foundCollection, found := collections.Find(denom2) + require.False(t, found) + require.Empty(t, foundCollection) + + collections = NewCollections(collection, collection2) + + foundCollection, found = collections.Find(denom2) + require.True(t, found) + require.Equal(t, foundCollection.String(), collection2.String()) + + collection3 := NewCollection(denom3, nfts) + collections = NewCollections(collection, collection2, collection3) + + _, found = collections.Find(denom) + require.True(t, found) + + _, found = collections.Find(denom2) + require.True(t, found) + + _, found = collections.Find(denom3) + require.True(t, found) +} + +func TestCollectionsRemoveMethod(t *testing.T) { + + testNFT := NewBaseNFT(id, address, tokenURI) + nfts := NewNFTs(&testNFT) + collection := NewCollection(denom, nfts) + + collections := NewCollections(collection) + + returnedCollections, removed := collections.Remove(denom2) + require.False(t, removed) + require.Equal(t, returnedCollections.String(), collections.String()) + + testNFT2 := NewBaseNFT(id2, address2, tokenURI2) + nfts2 := NewNFTs(&testNFT2) + collection2 := NewCollection(denom2, nfts2) + + collections = NewCollections(collection, collection2) + + returnedCollections, removed = collections.Remove(denom2) + require.True(t, removed) + require.NotEqual(t, returnedCollections.String(), collections.String()) + require.Equal(t, 1, len(returnedCollections)) + + foundCollection, found := returnedCollections.Find(denom2) + require.False(t, found) + require.Empty(t, foundCollection) +} + +func TestCollectionsStringMethod(t *testing.T) { + collections := NewCollections() + require.Equal(t, collections.String(), "") + + testNFT := NewBaseNFT(id, address, tokenURI) + nfts := NewNFTs(&testNFT) + collection := NewCollection(denom, nfts) + + testNFT2 := NewBaseNFT(id2, address2, tokenURI2) + nfts2 := NewNFTs(&testNFT2) + collection2 := NewCollection(denom2, nfts2) + + collections = NewCollections(collection, collection2) + require.Equal(t, fmt.Sprintf(`Denom: %s +NFTs: + +ID: %s +Owner: %s +TokenURI: %s +Denom: %s +NFTs: + +ID: %s +Owner: %s +TokenURI: %s`, denom, id, address.String(), tokenURI, + denom2, id2, address2.String(), tokenURI2), collections.String()) + +} + +func TestCollectionsEmptyMethod(t *testing.T) { + + collections := NewCollections() + require.True(t, collections.Empty()) + + testNFT := NewBaseNFT(id, address, tokenURI) + nfts := NewNFTs(&testNFT) + collection := NewCollection(denom, nfts) + + collections = NewCollections(collection) + require.False(t, collections.Empty()) + +} + +func TestCollectionsSortInterface(t *testing.T) { + testNFT := NewBaseNFT(id, address, tokenURI) + nfts := NewNFTs(&testNFT) + collection := NewCollection(denom, nfts) + + testNFT2 := NewBaseNFT(id2, address2, tokenURI2) + nfts2 := NewNFTs(&testNFT2) + collection2 := NewCollection(denom2, nfts2) + + collections := NewCollections(collection, collection2) + require.Equal(t, 2, collections.Len()) + + require.True(t, collections.Less(0, 1)) + require.False(t, collections.Less(1, 0)) + + collections.Swap(0, 1) + require.False(t, collections.Less(0, 1)) + require.True(t, collections.Less(1, 0)) + + collections.Sort() + require.True(t, collections.Less(0, 1)) + require.False(t, collections.Less(1, 0)) +} + +func TestCollectionMarshalAndUnmarshalJSON(t *testing.T) { + testNFT := NewBaseNFT(id, address, tokenURI) + nfts := NewNFTs(&testNFT) + collection := NewCollection(denom, nfts) + + testNFT2 := NewBaseNFT(id2, address2, tokenURI2) + nfts2 := NewNFTs(&testNFT2) + collection2 := NewCollection(denom2, nfts2) + + collections := NewCollections(collection, collection2) + + bz, err := collections.MarshalJSON() + require.NoError(t, err) + require.Equal(t, string(bz), fmt.Sprintf(`{"%s":{"nfts":{"%s":{"id":"%s","owner":"%s","token_uri":"%s"}}},"%s":{"nfts":{"%s":{"id":"%s","owner":"%s","token_uri":"%s"}}}}`, + denom, id, id, address.String(), tokenURI, + denom2, id2, id2, address2.String(), tokenURI2, + )) + + var newCollections Collections + err = newCollections.UnmarshalJSON(bz) + require.NoError(t, err) + + err = newCollections.UnmarshalJSON([]byte{}) + require.Error(t, err) +} diff --git a/x/nft/internal/types/errors.go b/x/nft/internal/types/errors.go new file mode 100644 index 000000000000..f832ddae148b --- /dev/null +++ b/x/nft/internal/types/errors.go @@ -0,0 +1,62 @@ +package types + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// CodeType definition +type CodeType = sdk.CodeType + +// NFT error code +const ( + DefaultCodespace sdk.CodespaceType = ModuleName + + CodeInvalidCollection CodeType = 650 + CodeUnknownCollection CodeType = 651 + CodeInvalidNFT CodeType = 652 + CodeUnknownNFT CodeType = 653 + CodeNFTAlreadyExists CodeType = 654 + CodeEmptyMetadata CodeType = 655 +) + +// ErrInvalidCollection is an error +func ErrInvalidCollection(codespace sdk.CodespaceType) sdk.Error { + return sdk.NewError(codespace, CodeInvalidCollection, "invalid NFT collection") +} + +// ErrUnknownCollection is an error +func ErrUnknownCollection(codespace sdk.CodespaceType, msg string) sdk.Error { + if msg != "" { + return sdk.NewError(codespace, CodeUnknownCollection, msg) + } + return sdk.NewError(codespace, CodeUnknownCollection, "unknown NFT collection") +} + +// ErrInvalidNFT is an error +func ErrInvalidNFT(codespace sdk.CodespaceType) sdk.Error { + return sdk.NewError(codespace, CodeInvalidNFT, "invalid NFT") +} + +// ErrNFTAlreadyExists is an error when an invalid NFT is minted +func ErrNFTAlreadyExists(codespace sdk.CodespaceType, msg string) sdk.Error { + if msg != "" { + return sdk.NewError(codespace, CodeNFTAlreadyExists, msg) + } + return sdk.NewError(codespace, CodeNFTAlreadyExists, "NFT already exists") +} + +// ErrUnknownNFT is an error +func ErrUnknownNFT(codespace sdk.CodespaceType, msg string) sdk.Error { + if msg != "" { + return sdk.NewError(codespace, CodeUnknownNFT, msg) + } + return sdk.NewError(codespace, CodeUnknownNFT, "unknown NFT") +} + +// ErrEmptyMetadata is an error when metadata is empty +func ErrEmptyMetadata(codespace sdk.CodespaceType, msg string) sdk.Error { + if msg != "" { + return sdk.NewError(codespace, CodeEmptyMetadata, msg) + } + return sdk.NewError(codespace, CodeEmptyMetadata, "NFT metadata can't be empty") +} diff --git a/x/nft/internal/types/events.go b/x/nft/internal/types/events.go new file mode 100644 index 000000000000..db31c3af69d0 --- /dev/null +++ b/x/nft/internal/types/events.go @@ -0,0 +1,18 @@ +package types + +// NFT module event types +var ( + EventTypeTransfer = "transfer_nft" + EventTypeEditNFTMetadata = "edit_nft_metadata" + EventTypeMintNFT = "mint_nft" + EventTypeBurnNFT = "burn_nft" + + AttributeValueCategory = ModuleName + + AttributeKeySender = "sender" + AttributeKeyRecipient = "recipient" + AttributeKeyOwner = "owner" + AttributeKeyNFTID = "nft-id" + AttributeKeyNFTTokenURI = "token-uri" + AttributeKeyDenom = "denom" +) diff --git a/x/nft/internal/types/genesis.go b/x/nft/internal/types/genesis.go new file mode 100644 index 000000000000..7f69727fd9a0 --- /dev/null +++ b/x/nft/internal/types/genesis.go @@ -0,0 +1,26 @@ +package types + +// GenesisState is the state that must be provided at genesis. +type GenesisState struct { + Owners []Owner `json:"owners"` + Collections Collections `json:"collections"` +} + +// NewGenesisState creates a new genesis state. +func NewGenesisState(owners []Owner, collections Collections) GenesisState { + return GenesisState{ + Owners: owners, + Collections: collections, + } +} + +// DefaultGenesisState returns a default genesis state +func DefaultGenesisState() GenesisState { + return NewGenesisState([]Owner{}, NewCollections()) +} + +// ValidateGenesis performs basic validation of nfts genesis data returning an +// error for any failed validation criteria. +func ValidateGenesis(data GenesisState) error { + return nil +} diff --git a/x/nft/internal/types/keys.go b/x/nft/internal/types/keys.go new file mode 100644 index 000000000000..b93ad3a3515f --- /dev/null +++ b/x/nft/internal/types/keys.go @@ -0,0 +1,74 @@ +package types + +import ( + "fmt" + + "github.com/tendermint/tendermint/crypto/tmhash" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +const ( + // ModuleName is the name of the module + ModuleName = "nft" + + // StoreKey is the default store key for NFT + StoreKey = ModuleName + + // QuerierRoute is the querier route for the NFT store. + QuerierRoute = ModuleName + + // RouterKey is the message route for the NFT module + RouterKey = ModuleName +) + +// NFTs are stored as follow: +// +// - Colections: 0x00 : +// +// - Owners: 0x01: +var ( + CollectionsKeyPrefix = []byte{0x00} // key for NFT collections + OwnersKeyPrefix = []byte{0x01} // key for balance of NFTs held by an address +) + +// GetCollectionKey gets the key of a collection +func GetCollectionKey(denom string) []byte { + + h := tmhash.New() + _, err := h.Write([]byte(denom)) + if err != nil { + panic(err) + } + bs := h.Sum(nil) + + return append(CollectionsKeyPrefix, bs...) +} + +// SplitOwnerKey gets an address and denom from an owner key +func SplitOwnerKey(key []byte) (sdk.AccAddress, []byte) { + if len(key) != 53 { + panic(fmt.Sprintf("unexpected key length %d", len(key))) + } + address := key[1 : sdk.AddrLen+1] + denomHashBz := key[sdk.AddrLen+1:] + return sdk.AccAddress(address), denomHashBz +} + +// GetOwnersKey gets the key prefix for all the collections owned by an account address +func GetOwnersKey(address sdk.AccAddress) []byte { + return append(OwnersKeyPrefix, address.Bytes()...) +} + +// GetOwnerKey gets the key of a collection owned by an account address +func GetOwnerKey(address sdk.AccAddress, denom string) []byte { + + h := tmhash.New() + _, err := h.Write([]byte(denom)) + if err != nil { + panic(err) + } + bs := h.Sum(nil) + + return append(GetOwnersKey(address), bs...) +} diff --git a/x/nft/internal/types/msgs.go b/x/nft/internal/types/msgs.go new file mode 100644 index 000000000000..45adfa4c9c34 --- /dev/null +++ b/x/nft/internal/types/msgs.go @@ -0,0 +1,228 @@ +package types + +import ( + "strings" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +/* --------------------------------------------------------------------------- */ +// MsgTransferNFT +/* --------------------------------------------------------------------------- */ + +// MsgTransferNFT defines a TransferNFT message +type MsgTransferNFT struct { + Sender sdk.AccAddress + Recipient sdk.AccAddress + Denom string + ID string +} + +// NewMsgTransferNFT is a constructor function for MsgSetName +func NewMsgTransferNFT(sender, recipient sdk.AccAddress, denom, id string) MsgTransferNFT { + return MsgTransferNFT{ + Sender: sender, + Recipient: recipient, + Denom: strings.TrimSpace(denom), + ID: strings.TrimSpace(id), + } +} + +// Route Implements Msg +func (msg MsgTransferNFT) Route() string { return RouterKey } + +// Type Implements Msg +func (msg MsgTransferNFT) Type() string { return "transfer_nft" } + +// ValidateBasic Implements Msg. +func (msg MsgTransferNFT) ValidateBasic() sdk.Error { + if strings.TrimSpace(msg.Denom) == "" { + return ErrInvalidCollection(DefaultCodespace) + } + if msg.Sender.Empty() { + return sdk.ErrInvalidAddress("invalid sender address") + } + if msg.Recipient.Empty() { + return sdk.ErrInvalidAddress("invalid recipient address") + } + if strings.TrimSpace(msg.ID) == "" { + return ErrInvalidNFT(DefaultCodespace) + } + + return nil +} + +// GetSignBytes Implements Msg. +func (msg MsgTransferNFT) GetSignBytes() []byte { + bz := ModuleCdc.MustMarshalJSON(msg) + return sdk.MustSortJSON(bz) +} + +// GetSigners Implements Msg. +func (msg MsgTransferNFT) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.Sender} +} + +/* --------------------------------------------------------------------------- */ +// MsgEditNFTMetadata +/* --------------------------------------------------------------------------- */ + +// MsgEditNFTMetadata edits an NFT's metadata +type MsgEditNFTMetadata struct { + Sender sdk.AccAddress + ID string + Denom string + TokenURI string +} + +// NewMsgEditNFTMetadata is a constructor function for MsgSetName +func NewMsgEditNFTMetadata(sender sdk.AccAddress, id, + denom, tokenURI string, +) MsgEditNFTMetadata { + return MsgEditNFTMetadata{ + Sender: sender, + ID: strings.TrimSpace(id), + Denom: strings.TrimSpace(denom), + TokenURI: strings.TrimSpace(tokenURI), + } +} + +// Route Implements Msg +func (msg MsgEditNFTMetadata) Route() string { return RouterKey } + +// Type Implements Msg +func (msg MsgEditNFTMetadata) Type() string { return "edit_nft_metadata" } + +// ValidateBasic Implements Msg. +func (msg MsgEditNFTMetadata) ValidateBasic() sdk.Error { + if msg.Sender.Empty() { + return sdk.ErrInvalidAddress("invalid sender address") + } + if strings.TrimSpace(msg.ID) == "" { + return ErrInvalidNFT(DefaultCodespace) + } + if strings.TrimSpace(msg.Denom) == "" { + return ErrInvalidNFT(DefaultCodespace) + } + return nil +} + +// GetSignBytes Implements Msg. +func (msg MsgEditNFTMetadata) GetSignBytes() []byte { + bz := ModuleCdc.MustMarshalJSON(msg) + return sdk.MustSortJSON(bz) +} + +// GetSigners Implements Msg. +func (msg MsgEditNFTMetadata) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.Sender} +} + +/* --------------------------------------------------------------------------- */ +// MsgMintNFT +/* --------------------------------------------------------------------------- */ + +// MsgMintNFT defines a MintNFT message +type MsgMintNFT struct { + Sender sdk.AccAddress + Recipient sdk.AccAddress + ID string + Denom string + TokenURI string +} + +// NewMsgMintNFT is a constructor function for MsgMintNFT +func NewMsgMintNFT(sender, recipient sdk.AccAddress, id, denom, tokenURI string) MsgMintNFT { + return MsgMintNFT{ + Sender: sender, + Recipient: recipient, + ID: strings.TrimSpace(id), + Denom: strings.TrimSpace(denom), + TokenURI: strings.TrimSpace(tokenURI), + } +} + +// Route Implements Msg +func (msg MsgMintNFT) Route() string { return RouterKey } + +// Type Implements Msg +func (msg MsgMintNFT) Type() string { return "mint_nft" } + +// ValidateBasic Implements Msg. +func (msg MsgMintNFT) ValidateBasic() sdk.Error { + if strings.TrimSpace(msg.Denom) == "" { + return ErrInvalidNFT(DefaultCodespace) + } + if strings.TrimSpace(msg.ID) == "" { + return ErrInvalidNFT(DefaultCodespace) + } + if msg.Sender.Empty() { + return sdk.ErrInvalidAddress("invalid sender address") + } + if msg.Recipient.Empty() { + return sdk.ErrInvalidAddress("invalid recipient address") + } + return nil +} + +// GetSignBytes Implements Msg. +func (msg MsgMintNFT) GetSignBytes() []byte { + bz := ModuleCdc.MustMarshalJSON(msg) + return sdk.MustSortJSON(bz) +} + +// GetSigners Implements Msg. +func (msg MsgMintNFT) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.Sender} +} + +/* --------------------------------------------------------------------------- */ +// MsgBurnNFT +/* --------------------------------------------------------------------------- */ + +// MsgBurnNFT defines a BurnNFT message +type MsgBurnNFT struct { + Sender sdk.AccAddress + ID string + Denom string +} + +// NewMsgBurnNFT is a constructor function for MsgBurnNFT +func NewMsgBurnNFT(sender sdk.AccAddress, id string, denom string) MsgBurnNFT { + return MsgBurnNFT{ + Sender: sender, + ID: strings.TrimSpace(id), + Denom: strings.TrimSpace(denom), + } +} + +// Route Implements Msg +func (msg MsgBurnNFT) Route() string { return RouterKey } + +// Type Implements Msg +func (msg MsgBurnNFT) Type() string { return "burn_nft" } + +// ValidateBasic Implements Msg. +func (msg MsgBurnNFT) ValidateBasic() sdk.Error { + if strings.TrimSpace(msg.ID) == "" { + return ErrInvalidNFT(DefaultCodespace) + } + if strings.TrimSpace(msg.Denom) == "" { + return ErrInvalidNFT(DefaultCodespace) + } + if msg.Sender.Empty() { + return sdk.ErrInvalidAddress("invalid sender address") + } + return nil +} + +// GetSignBytes Implements Msg. +func (msg MsgBurnNFT) GetSignBytes() []byte { + bz := ModuleCdc.MustMarshalJSON(msg) + return sdk.MustSortJSON(bz) +} + +// GetSigners Implements Msg. +func (msg MsgBurnNFT) GetSigners() []sdk.AccAddress { + return []sdk.AccAddress{msg.Sender} +} diff --git a/x/nft/internal/types/msgs_test.go b/x/nft/internal/types/msgs_test.go new file mode 100644 index 000000000000..86fa33750a06 --- /dev/null +++ b/x/nft/internal/types/msgs_test.go @@ -0,0 +1,200 @@ +package types + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +// ---------------------------------------- Msgs --------------------------------------------------- + +func TestNewMsgTransferNFT(t *testing.T) { + newMsgTransferNFT := NewMsgTransferNFT(address, address2, + fmt.Sprintf(" %s ", denom), + fmt.Sprintf(" %s ", id)) + require.Equal(t, newMsgTransferNFT.Sender, address) + require.Equal(t, newMsgTransferNFT.Recipient, address2) + require.Equal(t, newMsgTransferNFT.Denom, denom) + require.Equal(t, newMsgTransferNFT.ID, id) +} + +func TestMsgTransferNFTValidateBasicMethod(t *testing.T) { + + newMsgTransferNFT := NewMsgTransferNFT(address, address2, "", id) + err := newMsgTransferNFT.ValidateBasic() + require.Error(t, err) + + newMsgTransferNFT = NewMsgTransferNFT(address, address2, denom, "") + err = newMsgTransferNFT.ValidateBasic() + require.Error(t, err) + + newMsgTransferNFT = NewMsgTransferNFT(nil, address2, denom, "") + err = newMsgTransferNFT.ValidateBasic() + require.Error(t, err) + + newMsgTransferNFT = NewMsgTransferNFT(address, nil, denom, "") + err = newMsgTransferNFT.ValidateBasic() + require.Error(t, err) + + newMsgTransferNFT = NewMsgTransferNFT(address, address2, denom, id) + err = newMsgTransferNFT.ValidateBasic() + require.NoError(t, err) +} + +func TestMsgTransferNFTGetSignBytesMethod(t *testing.T) { + newMsgTransferNFT := NewMsgTransferNFT(address, address2, denom, id) + sortedBytes := newMsgTransferNFT.GetSignBytes() + require.Equal(t, string(sortedBytes), fmt.Sprintf(`{"type":"cosmos-sdk/MsgTransferNFT","value":{"Denom":"%s","ID":"%s","Recipient":"%s","Sender":"%s"}}`, + denom, id, address2, address, + )) +} + +func TestMsgTransferNFTGetSignersMethod(t *testing.T) { + newMsgTransferNFT := NewMsgTransferNFT(address, address2, denom, id) + signers := newMsgTransferNFT.GetSigners() + require.Equal(t, 1, len(signers)) + require.Equal(t, address.String(), signers[0].String()) +} + +func TestNewMsgEditNFTMetadata(t *testing.T) { + newMsgEditNFTMetadata := NewMsgEditNFTMetadata(address, + fmt.Sprintf(" %s ", id), + fmt.Sprintf(" %s ", denom), + fmt.Sprintf(" %s ", tokenURI)) + + require.Equal(t, newMsgEditNFTMetadata.Sender.String(), address.String()) + require.Equal(t, newMsgEditNFTMetadata.ID, id) + require.Equal(t, newMsgEditNFTMetadata.Denom, denom) + require.Equal(t, newMsgEditNFTMetadata.TokenURI, tokenURI) +} + +func TestMsgEditNFTMetadataValidateBasicMethod(t *testing.T) { + + newMsgEditNFTMetadata := NewMsgEditNFTMetadata(nil, id, denom, tokenURI) + + err := newMsgEditNFTMetadata.ValidateBasic() + require.Error(t, err) + + newMsgEditNFTMetadata = NewMsgEditNFTMetadata(address, "", denom, tokenURI) + err = newMsgEditNFTMetadata.ValidateBasic() + require.Error(t, err) + + newMsgEditNFTMetadata = NewMsgEditNFTMetadata(address, id, "", tokenURI) + err = newMsgEditNFTMetadata.ValidateBasic() + require.Error(t, err) + + newMsgEditNFTMetadata = NewMsgEditNFTMetadata(address, id, denom, tokenURI) + err = newMsgEditNFTMetadata.ValidateBasic() + require.NoError(t, err) +} + +func TestMsgEditNFTMetadataGetSignBytesMethod(t *testing.T) { + newMsgEditNFTMetadata := NewMsgEditNFTMetadata(address, id, denom, tokenURI) + sortedBytes := newMsgEditNFTMetadata.GetSignBytes() + require.Equal(t, string(sortedBytes), fmt.Sprintf(`{"type":"cosmos-sdk/MsgEditNFTMetadata","value":{"Denom":"%s","ID":"%s","Sender":"%s","TokenURI":"%s"}}`, + denom, id, address.String(), tokenURI, + )) +} + +func TestMsgEditNFTMetadataGetSignersMethod(t *testing.T) { + newMsgEditNFTMetadata := NewMsgEditNFTMetadata(address, id, denom, tokenURI) + signers := newMsgEditNFTMetadata.GetSigners() + require.Equal(t, 1, len(signers)) + require.Equal(t, address.String(), signers[0].String()) +} + +func TestNewMsgMintNFT(t *testing.T) { + newMsgMintNFT := NewMsgMintNFT(address, address2, + fmt.Sprintf(" %s ", id), + fmt.Sprintf(" %s ", denom), + fmt.Sprintf(" %s ", tokenURI)) + + require.Equal(t, newMsgMintNFT.Sender.String(), address.String()) + require.Equal(t, newMsgMintNFT.Recipient.String(), address2.String()) + require.Equal(t, newMsgMintNFT.ID, id) + require.Equal(t, newMsgMintNFT.Denom, denom) + require.Equal(t, newMsgMintNFT.TokenURI, tokenURI) +} + +func TestMsgMsgMintNFTValidateBasicMethod(t *testing.T) { + + newMsgMintNFT := NewMsgMintNFT(nil, address2, id, denom, tokenURI) + err := newMsgMintNFT.ValidateBasic() + require.Error(t, err) + + newMsgMintNFT = NewMsgMintNFT(address, nil, id, denom, tokenURI) + err = newMsgMintNFT.ValidateBasic() + require.Error(t, err) + + newMsgMintNFT = NewMsgMintNFT(address, address2, "", denom, tokenURI) + err = newMsgMintNFT.ValidateBasic() + require.Error(t, err) + + newMsgMintNFT = NewMsgMintNFT(address, address2, id, "", tokenURI) + err = newMsgMintNFT.ValidateBasic() + require.Error(t, err) + + newMsgMintNFT = NewMsgMintNFT(address, address2, id, denom, tokenURI) + err = newMsgMintNFT.ValidateBasic() + require.NoError(t, err) +} + +func TestMsgMintNFTGetSignBytesMethod(t *testing.T) { + newMsgMintNFT := NewMsgMintNFT(address, address2, id, denom, tokenURI) + sortedBytes := newMsgMintNFT.GetSignBytes() + require.Equal(t, string(sortedBytes), fmt.Sprintf(`{"type":"cosmos-sdk/MsgMintNFT","value":{"Denom":"%s","ID":"%s","Recipient":"%s","Sender":"%s","TokenURI":"%s"}}`, + denom, id, address2.String(), address.String(), tokenURI, + )) +} + +func TestMsgMintNFTGetSignersMethod(t *testing.T) { + newMsgMintNFT := NewMsgMintNFT(address, address2, id, denom, tokenURI) + signers := newMsgMintNFT.GetSigners() + require.Equal(t, 1, len(signers)) + require.Equal(t, address.String(), signers[0].String()) +} + +func TestNewMsgBurnNFT(t *testing.T) { + newMsgBurnNFT := NewMsgBurnNFT(address, + fmt.Sprintf(" %s ", id), + fmt.Sprintf(" %s ", denom)) + + require.Equal(t, newMsgBurnNFT.Sender.String(), address.String()) + require.Equal(t, newMsgBurnNFT.ID, id) + require.Equal(t, newMsgBurnNFT.Denom, denom) +} + +func TestMsgMsgBurnNFTValidateBasicMethod(t *testing.T) { + + newMsgBurnNFT := NewMsgBurnNFT(nil, id, denom) + err := newMsgBurnNFT.ValidateBasic() + require.Error(t, err) + + newMsgBurnNFT = NewMsgBurnNFT(address, "", denom) + err = newMsgBurnNFT.ValidateBasic() + require.Error(t, err) + + newMsgBurnNFT = NewMsgBurnNFT(address, id, "") + err = newMsgBurnNFT.ValidateBasic() + require.Error(t, err) + + newMsgBurnNFT = NewMsgBurnNFT(address, id, denom) + err = newMsgBurnNFT.ValidateBasic() + require.NoError(t, err) +} + +func TestMsgBurnNFTGetSignBytesMethod(t *testing.T) { + newMsgBurnNFT := NewMsgBurnNFT(address, id, denom) + sortedBytes := newMsgBurnNFT.GetSignBytes() + require.Equal(t, string(sortedBytes), fmt.Sprintf(`{"type":"cosmos-sdk/MsgBurnNFT","value":{"Denom":"%s","ID":"%s","Sender":"%s"}}`, + denom, id, address.String(), + )) +} + +func TestMsgBurnNFTGetSignersMethod(t *testing.T) { + newMsgBurnNFT := NewMsgBurnNFT(address, id, denom) + signers := newMsgBurnNFT.GetSigners() + require.Equal(t, 1, len(signers)) + require.Equal(t, address.String(), signers[0].String()) +} diff --git a/x/nft/internal/types/nft.go b/x/nft/internal/types/nft.go new file mode 100644 index 000000000000..7a4e5489e19c --- /dev/null +++ b/x/nft/internal/types/nft.go @@ -0,0 +1,189 @@ +package types + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/nft/exported" +) + +var _ exported.NFT = (*BaseNFT)(nil) + +// BaseNFT non fungible token definition +type BaseNFT struct { + ID string `json:"id,omitempty" yaml:"id"` // id of the token; not exported to clients + Owner sdk.AccAddress `json:"owner" yaml:"owner"` // account address that owns the NFT + TokenURI string `json:"token_uri" yaml:"token_uri"` // optional extra properties available for querying +} + +// NewBaseNFT creates a new NFT instance +func NewBaseNFT(id string, owner sdk.AccAddress, tokenURI string) BaseNFT { + return BaseNFT{ + ID: id, + Owner: owner, + TokenURI: strings.TrimSpace(tokenURI), + } +} + +// GetID returns the ID of the token +func (bnft BaseNFT) GetID() string { return bnft.ID } + +// GetOwner returns the account address that owns the NFT +func (bnft BaseNFT) GetOwner() sdk.AccAddress { return bnft.Owner } + +// SetOwner updates the owner address of the NFT +func (bnft *BaseNFT) SetOwner(address sdk.AccAddress) { + bnft.Owner = address +} + +// GetTokenURI returns the path to optional extra properties +func (bnft BaseNFT) GetTokenURI() string { return bnft.TokenURI } + +// EditMetadata edits metadata of an nft +func (bnft *BaseNFT) EditMetadata(tokenURI string) { + bnft.TokenURI = tokenURI +} + +func (bnft BaseNFT) String() string { + return fmt.Sprintf(`ID: %s +Owner: %s +TokenURI: %s`, + bnft.ID, + bnft.Owner, + bnft.TokenURI, + ) +} + +// ---------------------------------------------------------------------------- +// NFT + +// NFTs define a list of NFT +type NFTs []exported.NFT + +// NewNFTs creates a new set of NFTs +func NewNFTs(nfts ...exported.NFT) NFTs { + if len(nfts) == 0 { + return NFTs{} + } + return NFTs(nfts) +} + +// Add appends two sets of NFTs +func (nfts NFTs) Add(nftsB NFTs) NFTs { + return append(nfts, nftsB...) +} + +// Find returns the searched collection from the set +func (nfts NFTs) Find(id string) (nft exported.NFT, found bool) { + index := nfts.find(id) + if index == -1 { + return nft, false + } + return nfts[index], true +} + +// Update removes and replaces an NFT from the set +func (nfts NFTs) Update(id string, nft exported.NFT) (NFTs, bool) { + index := nfts.find(id) + if index == -1 { + return nfts, false + } + + return append(append(nfts[:index], nft), nfts[index+1:]...), true +} + +// Remove removes an NFT from the set of NFTs +func (nfts NFTs) Remove(id string) (NFTs, bool) { + index := nfts.find(id) + if index == -1 { + return nfts, false + } + + return append(nfts[:index], nfts[index+1:]...), true +} + +// String follows stringer interface +func (nfts NFTs) String() string { + if len(nfts) == 0 { + return "" + } + + out := "" + for _, nft := range nfts { + out += fmt.Sprintf("%v\n", nft.String()) + } + return out[:len(out)-1] +} + +// Empty returns true if there are no NFTs and false otherwise. +func (nfts NFTs) Empty() bool { + return len(nfts) == 0 +} + +func (nfts NFTs) find(id string) int { + if len(nfts) == 0 { + return -1 + } + + midIdx := len(nfts) / 2 + nft := nfts[midIdx] + + switch { + case strings.Compare(id, nft.GetID()) == -1: + return nfts[:midIdx].find(id) + case id == nft.GetID(): + return midIdx + default: + return nfts[midIdx+1:].find(id) + } +} + +// ---------------------------------------------------------------------------- +// Encoding + +// NFTJSON is the exported NFT format for clients +type NFTJSON map[string]BaseNFT + +// MarshalJSON for NFTs +func (nfts NFTs) MarshalJSON() ([]byte, error) { + nftJSON := make(NFTJSON) + for _, nft := range nfts { + id := nft.GetID() + bnft := NewBaseNFT(id, nft.GetOwner(), nft.GetTokenURI()) + nftJSON[id] = bnft + } + return json.Marshal(nftJSON) +} + +// UnmarshalJSON for NFTs +func (nfts *NFTs) UnmarshalJSON(b []byte) error { + nftJSON := make(NFTJSON) + if err := json.Unmarshal(b, &nftJSON); err != nil { + return err + } + + for id, nft := range nftJSON { + bnft := NewBaseNFT(id, nft.GetOwner(), nft.GetTokenURI()) + *nfts = append(*nfts, &bnft) + } + return nil +} + +//----------------------------------------------------------------------------- +// Sort interface + +//nolint +func (nfts NFTs) Len() int { return len(nfts) } +func (nfts NFTs) Less(i, j int) bool { return strings.Compare(nfts[i].GetID(), nfts[j].GetID()) == -1 } +func (nfts NFTs) Swap(i, j int) { nfts[i], nfts[j] = nfts[j], nfts[i] } + +var _ sort.Interface = NFTs{} + +// Sort is a helper function to sort the set of coins inplace +func (nfts NFTs) Sort() NFTs { + sort.Sort(nfts) + return nfts +} diff --git a/x/nft/internal/types/nft_test.go b/x/nft/internal/types/nft_test.go new file mode 100644 index 000000000000..5e23d550f465 --- /dev/null +++ b/x/nft/internal/types/nft_test.go @@ -0,0 +1,183 @@ +package types + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +// ---------------------------------------- BaseNFT --------------------------------------------------- + +func TestBaseNFTGetMethods(t *testing.T) { + + testNFT := NewBaseNFT(id, address, tokenURI) + + require.Equal(t, id, testNFT.GetID()) + require.Equal(t, address, testNFT.GetOwner()) + require.Equal(t, tokenURI, testNFT.GetTokenURI()) +} + +func TestBaseNFTSetMethods(t *testing.T) { + + testNFT := NewBaseNFT(id, address, tokenURI) + + testNFT.SetOwner(address2) + require.Equal(t, address2, testNFT.GetOwner()) + + testNFT.EditMetadata(tokenURI2) + require.Equal(t, tokenURI2, testNFT.GetTokenURI()) +} + +func TestBaseNFTStringFormat(t *testing.T) { + testNFT := NewBaseNFT(id, address, tokenURI) + expected := fmt.Sprintf(`ID: %s +Owner: %s +TokenURI: %s`, + id, address, tokenURI) + require.Equal(t, expected, testNFT.String()) +} + +// ---------------------------------------- NFTs --------------------------------------------------- + +func TestNewNFTs(t *testing.T) { + + emptyNFTs := NewNFTs() + require.Equal(t, len(emptyNFTs), 0) + + testNFT := NewBaseNFT(id, address, tokenURI) + oneNFTs := NewNFTs(&testNFT) + require.Equal(t, len(oneNFTs), 1) + + testNFT2 := NewBaseNFT(id2, address, tokenURI) + twoNFTs := NewNFTs(&testNFT, &testNFT2) + require.Equal(t, len(twoNFTs), 2) + +} + +func TestNFTsAddMethod(t *testing.T) { + testNFT := NewBaseNFT(id, address, tokenURI) + nfts := NewNFTs(&testNFT) + require.Equal(t, len(nfts), 1) + + testNFT2 := NewBaseNFT(id2, address, tokenURI) + nfts2 := NewNFTs(&testNFT2) + + nfts = nfts.Add(nfts2) + require.Equal(t, len(nfts), 2) +} + +func TestNFTsFindMethod(t *testing.T) { + testNFT := NewBaseNFT(id, address, tokenURI) + testNFT2 := NewBaseNFT(id2, address, tokenURI) + nfts := NewNFTs(&testNFT, &testNFT2) + + nft, found := nfts.Find(id) + require.True(t, found) + require.Equal(t, nft.String(), testNFT.String()) + + nft, found = nfts.Find(id3) + require.False(t, found) + require.Nil(t, nft) +} + +func TestNFTsUpdateMethod(t *testing.T) { + testNFT := NewBaseNFT(id, address, tokenURI) + testNFT2 := NewBaseNFT(id2, address, tokenURI) + nfts := NewNFTs(&testNFT) + var success bool + nfts, success = nfts.Update(id, &testNFT2) + require.True(t, success) + + nft, found := nfts.Find(id2) + require.True(t, found) + require.Equal(t, nft.String(), testNFT2.String()) + + nft, found = nfts.Find(id) + require.False(t, found) + require.Nil(t, nft) + + var returnedNFTs NFTs + returnedNFTs, success = nfts.Update(id, &testNFT2) + require.False(t, success) + require.Equal(t, returnedNFTs.String(), nfts.String()) + +} + +func TestNFTsRemoveMethod(t *testing.T) { + + testNFT := NewBaseNFT(id, address, tokenURI) + testNFT2 := NewBaseNFT(id2, address, tokenURI) + nfts := NewNFTs(&testNFT, &testNFT2) + + var success bool + nfts, success = nfts.Remove(id) + require.True(t, success) + require.Equal(t, len(nfts), 1) + + nfts, success = nfts.Remove(id2) + require.True(t, success) + require.Equal(t, len(nfts), 0) + + var returnedNFTs NFTs + returnedNFTs, success = nfts.Remove(id2) + require.False(t, success) + require.Equal(t, nfts.String(), returnedNFTs.String()) +} + +func TestNFTsStringMethod(t *testing.T) { + testNFT := NewBaseNFT(id, address, tokenURI) + nfts := NewNFTs(&testNFT) + require.Equal(t, nfts.String(), fmt.Sprintf(`ID: %s +Owner: %s +TokenURI: %s`, id, address, tokenURI)) +} + +func TestNFTsEmptyMethod(t *testing.T) { + nfts := NewNFTs() + require.True(t, nfts.Empty()) + testNFT := NewBaseNFT(id, address, tokenURI) + nfts = NewNFTs(&testNFT) + require.False(t, nfts.Empty()) +} + +func TestNFTsMarshalUnmarshalJSON(t *testing.T) { + testNFT := NewBaseNFT(id, address, tokenURI) + nfts := NewNFTs(&testNFT) + bz, err := nfts.MarshalJSON() + require.NoError(t, err) + require.Equal(t, string(bz), + fmt.Sprintf(`{"%s":{"id":"%s","owner":"%s","token_uri":"%s"}}`, + id, id, address.String(), tokenURI)) + + var unmarshaledNFTs NFTs + err = unmarshaledNFTs.UnmarshalJSON(bz) + require.NoError(t, err) + require.Equal(t, unmarshaledNFTs.String(), nfts.String()) + + bz = []byte{} + err = unmarshaledNFTs.UnmarshalJSON(bz) + require.Error(t, err) +} + +func TestNFTsSortInterface(t *testing.T) { + testNFT := NewBaseNFT(id, address, tokenURI) + testNFT2 := NewBaseNFT(id2, address, tokenURI) + + nfts := NewNFTs(&testNFT) + require.Equal(t, nfts.Len(), 1) + + nfts = NewNFTs(&testNFT, &testNFT2) + require.Equal(t, nfts.Len(), 2) + + require.True(t, nfts.Less(0, 1)) + require.False(t, nfts.Less(1, 0)) + + nfts.Swap(0, 1) + require.False(t, nfts.Less(0, 1)) + require.True(t, nfts.Less(1, 0)) + + nfts.Sort() + require.True(t, nfts.Less(0, 1)) + require.False(t, nfts.Less(1, 0)) +} diff --git a/x/nft/internal/types/owners.go b/x/nft/internal/types/owners.go new file mode 100644 index 000000000000..0e6beed793ac --- /dev/null +++ b/x/nft/internal/types/owners.go @@ -0,0 +1,202 @@ +package types + +import ( + "fmt" + "strings" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// IDCollection defines a set of nft ids that belong to a specific +// collection +type IDCollection struct { + Denom string `json:"denom" yaml:"denom"` + IDs []string `json:"ids" yaml:"ids"` +} + +// NewIDCollection creates a new IDCollection instance +func NewIDCollection(denom string, ids []string) IDCollection { + return IDCollection{ + Denom: strings.TrimSpace(denom), + IDs: ids, + } +} + +// Exists determines whether an ID is in the IDCollection +func (idCollection IDCollection) Exists(id string) (exists bool) { + // TODO: improve performance + for _, _id := range idCollection.IDs { + if _id == id { + return true + } + } + return false +} + +// AddID adds an ID to the idCollection +func (idCollection IDCollection) AddID(id string) IDCollection { + idCollection.IDs = append(idCollection.IDs, id) + return idCollection +} + +// DeleteID deletes an ID from an ID Collection +func (idCollection IDCollection) DeleteID(id string) (IDCollection, sdk.Error) { + index := stringArray(idCollection.IDs).find(id) + if index == -1 { + return idCollection, ErrUnknownNFT(DefaultCodespace, + fmt.Sprintf("ID #%s doesn't exist on ID Collection %s", id, idCollection.Denom), + ) + } + + idCollection.IDs = append(idCollection.IDs[:index], idCollection.IDs[index+1:]...) + + return idCollection, nil +} + +// Supply gets the total supply of NFTIDs of a balance +func (idCollection IDCollection) Supply() int { + return len(idCollection.IDs) +} + +// String follows stringer interface +func (idCollection IDCollection) String() string { + return fmt.Sprintf(`Denom: %s +IDs: %s`, + idCollection.Denom, + strings.Join(idCollection.IDs, ","), + ) +} + +// ---------------------------------------------------------------------------- +// Owners + +// IDCollections is an array of ID Collections whose sole purpose is for find +type IDCollections []IDCollection + +// String follows stringer interface +func (idCollections IDCollections) String() string { + if len(idCollections) == 0 { + return "" + } + + out := "" + for _, idCollection := range idCollections { + out += fmt.Sprintf("%v\n", idCollection.String()) + } + return out[:len(out)-1] +} + +func (idCollections IDCollections) find(el string) int { + if len(idCollections) == 0 { + return -1 + } + + midIdx := len(idCollections) / 2 + midIDCollection := idCollections[midIdx] + + switch { + case strings.Compare(el, midIDCollection.Denom) == -1: + return idCollections[:midIdx].find(el) + case midIDCollection.Denom == el: + return midIdx + default: + return idCollections[midIdx+1:].find(el) + } +} + +// Owner of non fungible tokens +type Owner struct { + Address sdk.AccAddress `json:"address" yaml:"address"` + IDCollections IDCollections `json:"idCollections" yaml:"idCollections"` +} + +// NewOwner creates a new Owner +func NewOwner(owner sdk.AccAddress, idCollections ...IDCollection) Owner { + return Owner{ + Address: owner, + IDCollections: idCollections, + } +} + +// Supply gets the total supply of an Owner +func (owner Owner) Supply() int { + total := 0 + for _, idCollection := range owner.IDCollections { + total += idCollection.Supply() + } + return total +} + +// GetIDCollection gets the IDCollection from the owner +func (owner Owner) GetIDCollection(denom string) (IDCollection, bool) { + index := owner.IDCollections.find(denom) + if index == -1 { + return IDCollection{}, false + } + return owner.IDCollections[index], true +} + +// UpdateIDCollection updates the ID Collection of an owner +func (owner Owner) UpdateIDCollection(idCollection IDCollection) (Owner, sdk.Error) { + denom := idCollection.Denom + index := owner.IDCollections.find(denom) + if index == -1 { + return owner, ErrUnknownCollection(DefaultCodespace, + fmt.Sprintf("ID Collection %s doesn't exist for owner %s", denom, owner.Address), + ) + } + + owner.IDCollections = append(append(owner.IDCollections[:index], idCollection), owner.IDCollections[index+1:]...) + + return owner, nil +} + +// DeleteID deletes an ID from an owners ID Collection +func (owner Owner) DeleteID(denom string, id string) (Owner, sdk.Error) { + idCollection, found := owner.GetIDCollection(denom) + if !found { + return owner, ErrUnknownNFT(DefaultCodespace, + fmt.Sprintf("ID #%s doesn't exist in ID Collection %s", id, denom), + ) + } + idCollection, err := idCollection.DeleteID(id) + if err != nil { + return owner, err + } + owner, err = owner.UpdateIDCollection(idCollection) + if err != nil { + return owner, err + } + return owner, nil +} + +// String follows stringer interface +func (owner Owner) String() string { + return fmt.Sprintf(` + Address: %s + IDCollections: %s`, + owner.Address, + owner.IDCollections.String(), + ) +} + +// stringArray is an array of strings whose sole purpose is to help with find +type stringArray []string + +func (sa stringArray) find(el string) (idx int) { + if len(sa) == 0 { + return -1 + } + + midIdx := len(sa) / 2 + stringArrayEl := sa[midIdx] + + switch { + case strings.Compare(el, stringArrayEl) == -1: + return sa[:midIdx].find(el) + case stringArrayEl == el: + return midIdx + default: + return sa[midIdx+1:].find(el) + } +} diff --git a/x/nft/internal/types/owners_test.go b/x/nft/internal/types/owners_test.go new file mode 100644 index 000000000000..c3ac37426523 --- /dev/null +++ b/x/nft/internal/types/owners_test.go @@ -0,0 +1,199 @@ +package types + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +// ---------------------------------------- IDCollection --------------------------------------------------- + +func TestNewIDCollection(t *testing.T) { + ids := []string{id, id2, id3} + idCollection := NewIDCollection(denom, ids) + require.Equal(t, idCollection.Denom, denom) + require.Equal(t, len(idCollection.IDs), 3) +} + +func TestIDCollectionExistsMethod(t *testing.T) { + ids := []string{id, id2} + idCollection := NewIDCollection(denom, ids) + require.True(t, idCollection.Exists(id)) + require.True(t, idCollection.Exists(id2)) + require.False(t, idCollection.Exists(id3)) +} + +func TestIDCollectionAddIDMethod(t *testing.T) { + ids := []string{id, id2} + idCollection := NewIDCollection(denom, ids) + idCollection = idCollection.AddID(id3) + require.Equal(t, len(idCollection.IDs), 3) +} + +func TestIDCollectionDeleteIDMethod(t *testing.T) { + ids := []string{id, id2} + idCollection := NewIDCollection(denom, ids) + newIDCollection, err := idCollection.DeleteID(id3) + require.Error(t, err) + require.Equal(t, idCollection.String(), newIDCollection.String()) + + idCollection, err = idCollection.DeleteID(id2) + require.NoError(t, err) + require.Equal(t, len(idCollection.IDs), 1) +} + +func TestIDCollectionSupplyMethod(t *testing.T) { + idCollectionEmpty := IDCollection{} + require.Equal(t, 0, idCollectionEmpty.Supply()) + + ids := []string{id, id2} + idCollection := NewIDCollection(denom, ids) + require.Equal(t, 2, idCollection.Supply()) + + idCollection, err := idCollection.DeleteID(id) + require.Nil(t, err) + require.Equal(t, idCollection.Supply(), 1) + + idCollection, err = idCollection.DeleteID(id2) + require.Nil(t, err) + require.Equal(t, idCollection.Supply(), 0) + + idCollection = idCollection.AddID(id) + require.Nil(t, err) + require.Equal(t, idCollection.Supply(), 1) + +} + +func TestIDCollectionStringMethod(t *testing.T) { + ids := []string{id, id2} + idCollection := NewIDCollection(denom, ids) + require.Equal(t, idCollection.String(), fmt.Sprintf(`Denom: %s +IDs: %s,%s`, denom, id, id2)) +} + +// ---------------------------------------- IDCollections --------------------------------------------------- + +func TestIDCollectionsString(t *testing.T) { + + emptyCollections := IDCollections([]IDCollection{}) + require.Equal(t, emptyCollections.String(), "") + + ids := []string{id, id2} + idCollection := NewIDCollection(denom, ids) + idCollection2 := NewIDCollection(denom2, ids) + + idCollections := IDCollections([]IDCollection{idCollection, idCollection2}) + require.Equal(t, idCollections.String(), fmt.Sprintf(`Denom: %s +IDs: %s,%s +Denom: %s +IDs: %s,%s`, denom, id, id2, denom2, id, id2)) +} + +// ---------------------------------------- Owner --------------------------------------------------- + +func TestNewOwner(t *testing.T) { + + ids := []string{id, id2} + idCollection := NewIDCollection(denom, ids) + idCollection2 := NewIDCollection(denom2, ids) + + owner := NewOwner(address, idCollection, idCollection2) + require.Equal(t, owner.Address.String(), address.String()) + require.Equal(t, len(owner.IDCollections), 2) +} + +func TestOwnerSupplyMethod(t *testing.T) { + + owner := NewOwner(address) + require.Equal(t, owner.Supply(), 0) + + ids := []string{id, id2} + idCollection := NewIDCollection(denom, ids) + owner = NewOwner(address, idCollection) + require.Equal(t, owner.Supply(), 2) + + idCollection2 := NewIDCollection(denom2, ids) + owner = NewOwner(address, idCollection, idCollection2) + require.Equal(t, owner.Supply(), 4) +} + +func TestOwnerGetIDCollectionMethod(t *testing.T) { + + ids := []string{id, id2} + idCollection := NewIDCollection(denom, ids) + owner := NewOwner(address, idCollection) + + gotCollection, found := owner.GetIDCollection(denom2) + require.False(t, found) + require.Equal(t, gotCollection.Denom, "") + require.Equal(t, len(gotCollection.IDs), 0) + require.Equal(t, gotCollection.String(), IDCollection{}.String()) + + gotCollection, found = owner.GetIDCollection(denom) + require.True(t, found) + require.Equal(t, gotCollection.String(), idCollection.String()) + + idCollection2 := NewIDCollection(denom2, ids) + owner = NewOwner(address, idCollection, idCollection2) + + gotCollection, found = owner.GetIDCollection(denom) + require.True(t, found) + require.Equal(t, gotCollection.String(), idCollection.String()) + + gotCollection, found = owner.GetIDCollection(denom2) + require.True(t, found) + require.Equal(t, gotCollection.String(), idCollection2.String()) +} + +func TestOwnerUpdateIDCollectionMethod(t *testing.T) { + ids := []string{id} + idCollection := NewIDCollection(denom, ids) + owner := NewOwner(address, idCollection) + require.Equal(t, owner.Supply(), 1) + + ids2 := []string{id, id2} + idCollection2 := NewIDCollection(denom2, ids2) + + // UpdateIDCollection should fail if denom doesn't exist + returnedOwner, err := owner.UpdateIDCollection(idCollection2) + require.Error(t, err) + + idCollection3 := NewIDCollection(denom, ids2) + returnedOwner, err = owner.UpdateIDCollection(idCollection3) + require.NoError(t, err) + require.Equal(t, returnedOwner.Supply(), 2) + + owner = returnedOwner + + returnedCollection, _ := owner.GetIDCollection(denom) + require.Equal(t, len(returnedCollection.IDs), 2) + + owner = NewOwner(address, idCollection, idCollection2) + require.Equal(t, owner.Supply(), 3) + + returnedOwner, err = owner.UpdateIDCollection(idCollection3) + require.NoError(t, err) + require.Equal(t, returnedOwner.Supply(), 4) + +} + +func TestOwnerDeleteIDMethod(t *testing.T) { + ids := []string{id, id2} + idCollection := NewIDCollection(denom, ids) + owner := NewOwner(address, idCollection) + + returnedOwner, err := owner.DeleteID(denom2, id) + require.Error(t, err) + require.Equal(t, owner.String(), returnedOwner.String()) + + returnedOwner, err = owner.DeleteID(denom, id3) + require.Error(t, err) + require.Equal(t, owner.String(), returnedOwner.String()) + + owner, err = owner.DeleteID(denom, id) + require.NoError(t, err) + + returnedCollection, _ := owner.GetIDCollection(denom) + require.Equal(t, len(returnedCollection.IDs), 1) +} diff --git a/x/nft/internal/types/querier.go b/x/nft/internal/types/querier.go new file mode 100644 index 000000000000..b84112ee0956 --- /dev/null +++ b/x/nft/internal/types/querier.go @@ -0,0 +1,55 @@ +package types + +// DONTCOVER + +import ( + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// QueryCollectionParams defines the params for queries: +// - 'custom/nft/supply' +// - 'custom/nft/collection' +type QueryCollectionParams struct { + Denom string +} + +// NewQueryCollectionParams creates a new instance of QuerySupplyParams +func NewQueryCollectionParams(denom string) QueryCollectionParams { + return QueryCollectionParams{Denom: denom} +} + +// Bytes exports the Denom as bytes +func (q QueryCollectionParams) Bytes() []byte { + return []byte(q.Denom) +} + +// QueryBalanceParams params for query 'custom/nfts/balance' +type QueryBalanceParams struct { + Owner sdk.AccAddress + Denom string // optional +} + +// NewQueryBalanceParams creates a new instance of QuerySupplyParams +func NewQueryBalanceParams(owner sdk.AccAddress, denom ...string) QueryBalanceParams { + if len(denom) > 0 { + return QueryBalanceParams{ + Owner: owner, + Denom: denom[0], + } + } + return QueryBalanceParams{Owner: owner} +} + +// QueryNFTParams params for query 'custom/nfts/nft' +type QueryNFTParams struct { + Denom string + TokenID string +} + +// NewQueryNFTParams creates a new instance of QueryNFTParams +func NewQueryNFTParams(denom, id string) QueryNFTParams { + return QueryNFTParams{ + Denom: denom, + TokenID: id, + } +} diff --git a/x/nft/internal/types/test_common.go b/x/nft/internal/types/test_common.go new file mode 100644 index 000000000000..400d1baf3002 --- /dev/null +++ b/x/nft/internal/types/test_common.go @@ -0,0 +1,65 @@ +package types + +import ( + "bytes" + "strconv" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// nolint: deadcode unused +var ( + denom = "denom" + denom2 = "test-denom2" + denom3 = "test-denom3" + id = "1" + id2 = "2" + id3 = "3" + address = CreateTestAddrs(1)[0] + address2 = CreateTestAddrs(2)[1] + address3 = CreateTestAddrs(3)[2] + tokenURI = "https://google.com/token-1.json" + tokenURI2 = "https://google.com/token-2.json" +) + +// CreateTestAddrs creates test addresses +func CreateTestAddrs(numAddrs int) []sdk.AccAddress { + var addresses []sdk.AccAddress + var buffer bytes.Buffer + + // start at 100 so we can make up to 999 test addresses with valid test addresses + for i := 100; i < (numAddrs + 100); i++ { + numString := strconv.Itoa(i) + buffer.WriteString("A58856F0FD53BF058B4909A21AEC019107BA6") //base address string + + buffer.WriteString(numString) //adding on final two digits to make addresses unique + res, _ := sdk.AccAddressFromHex(buffer.String()) + bech := res.String() + addresses = append(addresses, testAddr(buffer.String(), bech)) + buffer.Reset() + } + return addresses +} + +// for incode address generation +func testAddr(addr string, bech string) sdk.AccAddress { + + res, err := sdk.AccAddressFromHex(addr) + if err != nil { + panic(err) + } + bechexpected := res.String() + if bech != bechexpected { + panic("Bech encoding doesn't match reference") + } + + bechres, err := sdk.AccAddressFromBech32(bech) + if err != nil { + panic(err) + } + if !bytes.Equal(bechres, res) { + panic("Bech decode and hex decode don't match") + } + + return res +} diff --git a/x/nft/module.go b/x/nft/module.go new file mode 100644 index 000000000000..9532eac7015a --- /dev/null +++ b/x/nft/module.go @@ -0,0 +1,154 @@ +package nft + +// DONTCOVER + +import ( + "encoding/json" + + "github.com/gorilla/mux" + "github.com/spf13/cobra" + + abci "github.com/tendermint/tendermint/abci/types" + + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/module" + "github.com/cosmos/cosmos-sdk/x/nft/client/cli" + "github.com/cosmos/cosmos-sdk/x/nft/client/rest" + "github.com/cosmos/cosmos-sdk/x/nft/simulation" +) + +var ( + _ module.AppModule = AppModule{} + _ module.AppModuleBasic = AppModuleBasic{} + _ module.AppModuleSimulation = AppModuleSimulation{} +) + +// AppModuleBasic app module basics object +type AppModuleBasic struct{} + +var _ module.AppModuleBasic = AppModuleBasic{} + +// Name defines module name +func (AppModuleBasic) Name() string { + return ModuleName +} + +// RegisterCodec registers module codec +func (AppModuleBasic) RegisterCodec(cdc *codec.Codec) { + RegisterCodec(cdc) +} + +// DefaultGenesis default genesis state +func (AppModuleBasic) DefaultGenesis() json.RawMessage { + return ModuleCdc.MustMarshalJSON(DefaultGenesisState()) +} + +// ValidateGenesis module validate genesis +func (AppModuleBasic) ValidateGenesis(bz json.RawMessage) error { + var data GenesisState + err := ModuleCdc.UnmarshalJSON(bz, &data) + if err != nil { + return err + } + return ValidateGenesis(data) +} + +// RegisterRESTRoutes registers rest routes +func (AppModuleBasic) RegisterRESTRoutes(ctx context.CLIContext, rtr *mux.Router) { + rest.RegisterRoutes(ctx, rtr, ModuleCdc, RouterKey) +} + +// GetTxCmd gets the root tx command of this module +func (AppModuleBasic) GetTxCmd(cdc *codec.Codec) *cobra.Command { + return cli.GetTxCmd(StoreKey, cdc) +} + +// GetQueryCmd gets the root query command of this module +func (AppModuleBasic) GetQueryCmd(cdc *codec.Codec) *cobra.Command { + return cli.GetQueryCmd(StoreKey, cdc) + +} + +//____________________________________________________________________________ + +// AppModuleSimulation defines the module simulation functions used by the gov module. +type AppModuleSimulation struct{} + +// RegisterStoreDecoder performs a no-op. +func (AppModuleSimulation) RegisterStoreDecoder(sdr sdk.StoreDecoderRegistry) { + sdr[StoreKey] = simulation.DecodeStore +} + +//____________________________________________________________________________ + +// AppModule supply app module +type AppModule struct { + AppModuleBasic + AppModuleSimulation + + keeper Keeper +} + +// NewAppModule creates a new AppModule object +func NewAppModule(keeper Keeper) AppModule { + + return AppModule{ + AppModuleBasic: AppModuleBasic{}, + AppModuleSimulation: AppModuleSimulation{}, + keeper: keeper, + } +} + +// Name defines module name +func (AppModule) Name() string { + return ModuleName +} + +// RegisterInvariants registers the nft module invariants +func (am AppModule) RegisterInvariants(ir sdk.InvariantRegistry) { + RegisterInvariants(ir, am.keeper) +} + +// Route module message route name +func (AppModule) Route() string { + return RouterKey +} + +// NewHandler module handler +func (am AppModule) NewHandler() sdk.Handler { + return GenericHandler(am.keeper) +} + +// QuerierRoute module querier route name +func (AppModule) QuerierRoute() string { + return QuerierRoute +} + +// NewQuerierHandler module querier +func (am AppModule) NewQuerierHandler() sdk.Querier { + return NewQuerier(am.keeper) +} + +// InitGenesis module init-genesis +func (am AppModule) InitGenesis(ctx sdk.Context, data json.RawMessage) []abci.ValidatorUpdate { + var genesisState GenesisState + ModuleCdc.MustUnmarshalJSON(data, &genesisState) + InitGenesis(ctx, am.keeper, genesisState) + return []abci.ValidatorUpdate{} +} + +// ExportGenesis module export genesis +func (am AppModule) ExportGenesis(ctx sdk.Context) json.RawMessage { + gs := ExportGenesis(ctx, am.keeper) + return ModuleCdc.MustMarshalJSON(gs) +} + +// BeginBlock module begin-block +func (AppModule) BeginBlock(_ sdk.Context, _ abci.RequestBeginBlock) {} + +// EndBlock module end-block +func (am AppModule) EndBlock(ctx sdk.Context, _ abci.RequestEndBlock) []abci.ValidatorUpdate { + return EndBlocker(ctx, am.keeper) +} diff --git a/x/nft/simulation/decoder.go b/x/nft/simulation/decoder.go new file mode 100644 index 000000000000..f76074becc7b --- /dev/null +++ b/x/nft/simulation/decoder.go @@ -0,0 +1,31 @@ +package simulation + +import ( + "bytes" + "fmt" + + cmn "github.com/tendermint/tendermint/libs/common" + + "github.com/cosmos/cosmos-sdk/codec" + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" +) + +// DecodeStore unmarshals the KVPair's Value to the corresponding gov type +func DecodeStore(cdc *codec.Codec, kvA, kvB cmn.KVPair) string { + switch { + case bytes.Equal(kvA.Key[:1], types.CollectionsKeyPrefix): + var collectionA, collectionB types.Collection + cdc.MustUnmarshalBinaryLengthPrefixed(kvA.Value, &collectionA) + cdc.MustUnmarshalBinaryLengthPrefixed(kvB.Value, &collectionB) + return fmt.Sprintf("%v\n%v", collectionA, collectionB) + + case bytes.Equal(kvA.Key[:1], types.OwnersKeyPrefix): + var idCollectionA, idCollectionB types.IDCollection + cdc.MustUnmarshalBinaryLengthPrefixed(kvA.Value, &idCollectionA) + cdc.MustUnmarshalBinaryLengthPrefixed(kvB.Value, &idCollectionB) + return fmt.Sprintf("%v\n%v", idCollectionA, idCollectionB) + + default: + panic(fmt.Sprintf("invalid %s key prefix %X", types.ModuleName, kvA.Key[:1])) + } +} diff --git a/x/nft/simulation/decoder_test.go b/x/nft/simulation/decoder_test.go new file mode 100644 index 000000000000..4b21c61dcaa4 --- /dev/null +++ b/x/nft/simulation/decoder_test.go @@ -0,0 +1,60 @@ +package simulation + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/tendermint/tendermint/crypto/ed25519" + cmn "github.com/tendermint/tendermint/libs/common" + + "github.com/cosmos/cosmos-sdk/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" +) + +var ( + delPk1 = ed25519.GenPrivKey().PubKey() + addr = sdk.AccAddress(delPk1.Address()) +) + +func makeTestCodec() (cdc *codec.Codec) { + cdc = codec.New() + sdk.RegisterCodec(cdc) + types.RegisterCodec(cdc) + return +} + +func TestDecodeStore(t *testing.T) { + cdc := makeTestCodec() + nft := types.NewBaseNFT("1", addr, "token URI") + collection := types.NewCollection("kitties", types.NFTs{&nft}) + idCollection := types.NewIDCollection("kitties", []string{"1", "2", "3"}) + + kvPairs := cmn.KVPairs{ + cmn.KVPair{Key: types.GetCollectionKey("kitties"), Value: cdc.MustMarshalBinaryLengthPrefixed(collection)}, + cmn.KVPair{Key: types.GetOwnerKey(addr, "kitties"), Value: cdc.MustMarshalBinaryLengthPrefixed(idCollection)}, + cmn.KVPair{Key: []byte{0x99}, Value: []byte{0x99}}, + } + + tests := []struct { + name string + expectedLog string + }{ + {"collections", fmt.Sprintf("%v\n%v", collection, collection)}, + {"owners", fmt.Sprintf("%v\n%v", idCollection, idCollection)}, + {"other", ""}, + } + + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + switch i { + case len(tests) - 1: + require.Panics(t, func() { DecodeStore(cdc, kvPairs[i], kvPairs[i]) }, tt.name) + default: + require.Equal(t, tt.expectedLog, DecodeStore(cdc, kvPairs[i], kvPairs[i]), tt.name) + } + }) + } +} diff --git a/x/nft/simulation/genesis.go b/x/nft/simulation/genesis.go new file mode 100644 index 000000000000..47a0c59bc74c --- /dev/null +++ b/x/nft/simulation/genesis.go @@ -0,0 +1,55 @@ +package simulation + +import ( + "encoding/json" + "fmt" + "math/rand" + + "github.com/cosmos/cosmos-sdk/codec" + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" + "github.com/cosmos/cosmos-sdk/x/simulation" +) + +// GenNFTGenesisState generates a random GenesisState for nft +func GenNFTGenesisState(cdc *codec.Codec, r *rand.Rand, accs []simulation.Account, ap simulation.AppParams, genesisState map[string]json.RawMessage) { + const ( + Kitties = "crypto-kitties" + Doggos = "crypto-doggos" + ) + + collections := types.NewCollections(types.NewCollection(Kitties, types.NFTs{}), types.NewCollection(Doggos, types.NFTs{})) + var ownerships []types.Owner + + for _, acc := range accs { + if r.Intn(100) < 50 { + baseNFT := types.NewBaseNFT( + simulation.RandStringOfLength(r, 10), // id + acc.Address, + simulation.RandStringOfLength(r, 45), // tokenURI + ) + + var idCollection types.IDCollection + var err error + if r.Intn(100) < 50 { + collections[0], err = collections[0].AddNFT(&baseNFT) + if err != nil { + panic(err) + } + idCollection = types.NewIDCollection(Kitties, []string{baseNFT.ID}) + } else { + collections[1], err = collections[1].AddNFT(&baseNFT) + if err != nil { + panic(err) + } + idCollection = types.NewIDCollection(Doggos, []string{baseNFT.ID}) + } + ownership := types.NewOwner(acc.Address, idCollection) + ownerships = append(ownerships, ownership) + } + } + + nftGenesis := types.NewGenesisState(ownerships, collections) + + fmt.Printf("Selected randomly generated NFT parameters:\n%s\n", codec.MustMarshalJSONIndent(cdc, nftGenesis)) + genesisState[types.ModuleName] = cdc.MustMarshalJSON(nftGenesis) +} diff --git a/x/nft/simulation/operations/msgs.go b/x/nft/simulation/operations/msgs.go new file mode 100644 index 000000000000..28036fd56de4 --- /dev/null +++ b/x/nft/simulation/operations/msgs.go @@ -0,0 +1,138 @@ +package operations + +import ( + "fmt" + "math/rand" + + "github.com/cosmos/cosmos-sdk/baseapp" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/nft" + "github.com/cosmos/cosmos-sdk/x/nft/internal/keeper" + "github.com/cosmos/cosmos-sdk/x/nft/internal/types" + "github.com/cosmos/cosmos-sdk/x/simulation" +) + +// DONTCOVER + +// SimulateMsgTransferNFT simulates the transfer of an NFT +func SimulateMsgTransferNFT(k keeper.Keeper) simulation.Operation { + handler := nft.GenericHandler(k) + return func(r *rand.Rand, app *baseapp.BaseApp, ctx sdk.Context, + accs []simulation.Account) (opMsg simulation.OperationMsg, fOps []simulation.FutureOperation, err error) { + + ownerAddr, denom, nftID := getRandomNFTFromOwner(ctx, k, r) + if ownerAddr.Empty() { + return simulation.NoOpMsg(types.ModuleName), nil, nil + } + + msg := types.NewMsgTransferNFT( + ownerAddr, // sender + simulation.RandomAcc(r, accs).Address, // recipient + denom, + nftID, + ) + + if msg.ValidateBasic() != nil { + return simulation.NoOpMsg(types.ModuleName), nil, fmt.Errorf("expected msg to pass ValidateBasic: %s", msg.GetSignBytes()) + } + + ctx, write := ctx.CacheContext() + ok := handler(ctx, msg).IsOK() + if ok { + write() + } + + opMsg = simulation.NewOperationMsg(msg, ok, "") + return opMsg, nil, nil + } +} + +// SimulateMsgEditNFTMetadata simulates an edit metadata transaction +func SimulateMsgEditNFTMetadata(k keeper.Keeper) simulation.Operation { + handler := nft.GenericHandler(k) + return func(r *rand.Rand, app *baseapp.BaseApp, ctx sdk.Context, + accs []simulation.Account) (opMsg simulation.OperationMsg, fOps []simulation.FutureOperation, err error) { + + ownerAddr, denom, nftID := getRandomNFTFromOwner(ctx, k, r) + if ownerAddr.Empty() { + return simulation.NoOpMsg(types.ModuleName), nil, nil + } + + msg := types.NewMsgEditNFTMetadata( + ownerAddr, + nftID, + denom, + simulation.RandStringOfLength(r, 45), // tokenURI + ) + + if msg.ValidateBasic() != nil { + return simulation.NoOpMsg(types.ModuleName), nil, fmt.Errorf("expected msg to pass ValidateBasic: %s", msg.GetSignBytes()) + } + + ctx, write := ctx.CacheContext() + ok := handler(ctx, msg).IsOK() + if ok { + write() + } + + opMsg = simulation.NewOperationMsg(msg, ok, "") + return opMsg, nil, nil + } +} + +// SimulateMsgMintNFT simulates a mint of an NFT +func SimulateMsgMintNFT(k keeper.Keeper) simulation.Operation { + handler := nft.GenericHandler(k) + return func(r *rand.Rand, app *baseapp.BaseApp, ctx sdk.Context, + accs []simulation.Account) (opMsg simulation.OperationMsg, fOps []simulation.FutureOperation, err error) { + + msg := types.NewMsgMintNFT( + simulation.RandomAcc(r, accs).Address, // sender + simulation.RandomAcc(r, accs).Address, // recipient + simulation.RandStringOfLength(r, 10), // nft ID + simulation.RandStringOfLength(r, 10), // denom + simulation.RandStringOfLength(r, 45), // tokenURI + ) + + if msg.ValidateBasic() != nil { + return simulation.NoOpMsg(types.ModuleName), nil, fmt.Errorf("expected msg to pass ValidateBasic: %s", msg.GetSignBytes()) + } + + ctx, write := ctx.CacheContext() + ok := handler(ctx, msg).IsOK() + if ok { + write() + } + + opMsg = simulation.NewOperationMsg(msg, ok, "") + return opMsg, nil, nil + } +} + +// SimulateMsgBurnNFT simulates a burn of an existing NFT +func SimulateMsgBurnNFT(k keeper.Keeper) simulation.Operation { + handler := nft.GenericHandler(k) + return func(r *rand.Rand, app *baseapp.BaseApp, ctx sdk.Context, + accs []simulation.Account) (opMsg simulation.OperationMsg, fOps []simulation.FutureOperation, err error) { + + ownerAddr, denom, nftID := getRandomNFTFromOwner(ctx, k, r) + if ownerAddr.Empty() { + return simulation.NoOpMsg(types.ModuleName), nil, nil + } + + msg := types.NewMsgBurnNFT(ownerAddr, nftID, denom) + + if msg.ValidateBasic() != nil { + return simulation.NoOpMsg(types.ModuleName), nil, fmt.Errorf("expected msg to pass ValidateBasic: %s", msg.GetSignBytes()) + } + + ctx, write := ctx.CacheContext() + ok := handler(ctx, msg).IsOK() + if ok { + write() + } + + opMsg = simulation.NewOperationMsg(msg, ok, "") + return opMsg, nil, nil + } +} diff --git a/x/nft/simulation/operations/random.go b/x/nft/simulation/operations/random.go new file mode 100644 index 000000000000..7f352b348c85 --- /dev/null +++ b/x/nft/simulation/operations/random.go @@ -0,0 +1,42 @@ +package operations + +import ( + "math/rand" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/nft/internal/keeper" +) + +func getRandomNFTFromOwner(ctx sdk.Context, k keeper.Keeper, r *rand.Rand) (address sdk.AccAddress, denom, nftID string) { + owners := k.GetOwners(ctx) + + ownersLen := len(owners) + if ownersLen == 0 { + return nil, "", "" + } + + // get random owner + i := r.Intn(ownersLen) + owner := owners[i] + + idCollectionsLen := len(owner.IDCollections) + if idCollectionsLen == 0 { + return nil, "", "" + } + + // get random collection from owner's balance + i = r.Intn(idCollectionsLen) + idsCollection := owner.IDCollections[i] // nfts IDs + denom = idsCollection.Denom + + idsLen := len(idsCollection.IDs) + if idsLen == 0 { + return nil, "", "" + } + + // get random nft from collection + i = r.Intn(idsLen) + nftID = idsCollection.IDs[i] + + return owner.Address, denom, nftID +}