Use the NFT Library

Your smart contract that can register names sounds an awful lot like the mint funtion of non-fungible tokens (NFTs). You even added a minter that gatekeeps the minting. Eventually, you can imagine adding functionality so that the name owners can transfer, or sell, those names.

Exercise progression

If you skipped the previous section, you can just switch the project to its add-first-library branch and take it from there.

Instead of reinventing the wheel, you could reuse an NFT library. cw721 is one such library. Its code is here. An additional advantage of using a library that acts close to a standard is that your smart contract is going to be compatible with other smart contracts that are compatible with the standard.

Let's refactor in order to use it. You are going to:

  1. Change the dependencies.
  2. Decide how much of the library you are going to use.
  3. Decide what to still declare in storage and what to delegate.
  4. Ditto for messages.
  5. Update the smart contract handling of messages.
  6. Update the tests.

Add the dependency

cargo add cw721 --git https://github.com/public-awesome/cw-nfts --tag "v0.19.0"

Note:

  • At the time of writing, the 0.19.0 version is not yet published, which is why you have to call it via Github.

Additionally:

  • The ownable and cw-storage-plus libraries come with cw721, so you don't need to mention them on their own:

    Cargo.toml
      ...
      [dependencies]
    - cw-ownable = "2.1.0"
    - cw-storage-plus = "2.0.0"
      ...
  • The current version requires CosmWasm v1.5+, so you have to downgrade your versions, including cw-multi-test, even though it is used by cw721:

    Cargo.toml
      ...
      [dependencies]
    - cosmwasm-schema = "2.1.3"
    - cosmwasm-std = "2.1.3"
    + cosmwasm-schema = "1.5.8"
    + cosmwasm-std = "1.5.8"
      ...
    
      [dev-dependencies]
    - cw-multi-test = "2.1.1"
    + cw-multi-test = "1.2.0"

Decide on the types

This NFT library is itself a large body of work and you have to decide on how you are going to use it. In its jargon, an extension is the additional information relative to the NFT, such as URL or even content, which could be stored on- or off-chain. In order to cleave as close as possible to what you have already done, you pick an empty extension, so that what remains are:

  • Token id, which maps directly to the registered name.
  • Owner, which is the same concept as previously.

How state changes

Since the NFT library takes care of the records and the minter, you do not need to declare anything:

src/state.rs
- use cosmwasm_schema::cw_serde;
- use cosmwasm_std::Addr;
- use cw_ownable::OwnershipStore;
- use cw_storage_plus::Map;

- #[cw_serde]
- pub struct NameRecord {
-     pub owner: Addr,
- }

- pub const NAME_RESOLVER: Map<&[u8], NameRecord> = Map::new("name_resolver");
- pub const MINTER: OwnershipStore = OwnershipStore::new("name_minter");

It is ok to keep an empty file to signify that having nothing is indeed a decision and not an omission.

How errors change

You get a new class of errors, those of the library. And since you delegate registration and minter actions, all you have to do is wrap the new class of errors:

src/error.rs
- use cosmwasm_std::{Addr, StdError};
- use cw_ownable::OwnershipError;
+ use cosmwasm_std::StdError;
+ use cw721::error::Cw721ContractError;
  use thiserror::Error;

  pub enum ContractError {
      #[error("{0}")]
      Std(#[from] StdError),
-     #[error("Name already taken ({name})")]
-     NameTaken { name: String },
-     #[error("Caller ({caller}) is not minter")]
-     Minter {
-         caller: String,
-         inner: OwnershipError,
-     },
+     #[error("{0}")]
+     Cw721(#[from] Cw721ContractError),
  }

- impl ContractError {
-     pub fn from_minter<'a>(caller: &'a Addr) -> impl Fn(OwnershipError) -> ContractError + 'a {
-         move |inner: OwnershipError| ContractError::Minter {
-             caller: caller.to_string(),
-             inner,
-         }
-     }
- }

Note how, thanks to the #from macro, Cw721ContractError can also be automagically converted to ContractError.

How messages change

Your goal with this change is also to maximize compatilibity. So to make sure that other smart contracts can communicate with yours, you keep the standard's messages unchanged, with the knowledge that you have picked an empty extension:

src/msg.rs
- use cosmwasm_schema::{cw_serde, QueryResponses};
- use cosmwasm_std::Addr;
+ use cosmwasm_std::Empty;
+ use cw721::msg::{Cw721ExecuteMsg, Cw721InstantiateMsg, Cw721QueryMsg};

- #[cw_serde]
- pub struct InstantiateMsg {
-     pub minter: String,
- }
+ pub type InstantiateMsg = Cw721InstantiateMsg<Option<Empty>>;

- #[cw_serde]
- pub enum ExecuteMsg {
-     Register { name: String, owner: Addr },
- }
+ pub type ExecuteMsg = Cw721ExecuteMsg<Option<Empty>, Option<Empty>, Empty>;

- #[cw_serde]
- #[derive(QueryResponses)]
- pub enum QueryMsg {
-     #[returns(ResolveRecordResponse)]
-     ResolveRecord { name: String },
- }
+ pub type QueryMsg = Cw721QueryMsg<Option<Empty>, Option<Empty>, Empty>;

- #[cw_serde]
- pub struct ResolveRecordResponse {
-     pub address: Option<String>,
- }

Note that defining type aliases is a convenience rather than a necessity.

How the contract changes

From here, all your smart contract has to do is to delegate actions to the equivalent ones from the library, with the knowledge that you have picked an empty extension. The library allows you a certain degree of configuration, but for your purposes, invoking the similarly-named functions on Cw721EmptyExtensions::default() gets you what you need.

src/contract.rs
- use crate::{
      error::ContractError,
-     msg::{ExecuteMsg, InstantiateMsg, QueryMsg, ResolveRecordResponse},
-     state::{NameRecord, MINTER, NAME_RESOLVER},
+     msg::{ExecuteMsg, InstantiateMsg, QueryMsg},
  };
  use cosmwasm_std::{
-     entry_point, to_json_binary, Addr, Binary, Deps, DepsMut, Env, Event, MessageInfo, Response, StdResult,
+     entry_point, Binary, Deps, DepsMut, Env, MessageInfo, Response,
  };
+ use cw721::{
+     extension::Cw721EmptyExtensions,
+     traits::{Cw721Execute, Cw721Query},
+ };

  type ContractResult = Result<Response, ContractError>;
+ type BinaryResult = Result<Binary, ContractError>;

  pub fn instantiate(
      deps: DepsMut,
-     _: Env,
-     _: MessageInfo,
+     env: Env,
+     info: MessageInfo,
      msg: InstantiateMsg,
  ) -> ContractResult {
-     let _ = MINTER.initialize_owner(deps.storage, deps.api, Some(msg.minter.as_str()))?;
-     Ok(Response::default())
+     Ok(Cw721EmptyExtensions::default().instantiate(deps, &env, &info, msg)?)
  }

  pub fn execute(
      deps: DepsMut,
-     _: Env,
+     env: Env,
      info: MessageInfo,
      msg: ExecuteMsg,
  ) -> ContractResult {
-   match msg {
-       ExecuteMsg::Register { name, owner } => execute_register(deps, info, name, &owner),
-   }
+     Ok(Cw721EmptyExtensions::default().execute(deps, &env, &info, msg)?)
  }

- fn execute_register(...) {
-     ...
- }

  ...

  pub fn query(
      deps: Deps,
-     _: Env,
+     env: Env,
      msg: QueryMsg,
- ) -> StdResult<Binary> {
-     match msg {
-         QueryMsg::ResolveRecord { name } => query_resolve_record(deps, name),
-     }
+ ) -> BinaryResult {
+     Ok(Cw721EmptyExtensions::default().query(deps, &env, msg)?)
  }

- fn query_resolve_record() {
-     ...
- }

Note how:

  • It's a matter of passing the error along with a ? to benefit from the #from macro constructor.
  • And a matter of wrapping the values in an Ok result.
  • You introduce the type BinaryResult so as to benefit succinctly from the error's #from constructor when querying.
  • You do not add your own event for convenience. To do so meaningfully, you would have to first find out which message type is passing through.
  • The Register equivalent in the library is Mint, which already emits its relevant events.

Update your unit tests

Many things have changed, but in essence, you mostly have to:

  • Adjust for the change of CosmWasm version from 2 to 1.
  • Change how your messages are built.
  • Change the storage checks with newly appropriate ones, including the storage keys.

The dummy instantiation message

The new InstantiateMsg has a long list of attributes, most of which you do not care much about in unit tests. It is worthwhile taking this into a separate function:

src/contract.rs
  ...
  mod tests {
      ...
+     fn simple_instantiate_msg(minter: String) -> InstantiateMsg {
+         InstantiateMsg {
+             name: "my names".to_owned(),
+             symbol: "MYN".to_owned(),
+             creator: None,
+             minter: Some(minter.to_string()),
+             collection_info_extension: None,
+             withdraw_address: None,
+         }
+     }
      ...

      #[test]
      fn test_instantiate() {
          ...
      }
  }

Instantiate

What you want to test is that you can instantiate and have the minter set as expected.

src/contract.rs
  ...
  mod tests {
      use crate::{
          msg::{ExecuteMsg, InstantiateMsg, QueryMsg},
-         state::{NameRecord, MINTER, NAME_RESOLVER},
      }
-     use cosmwasm_std::{testing, Addr, Api, Binary, CanonicalAddr, Event, Response};
+     use cosmwasm_std::{testing, Addr, Binary, Response};
+     use cw721::{
+         extension::Cw721EmptyExtensions,
+         state::{NftInfo, MINTER},
+     };

      ...

      #[test]
      fn test_instantiate() {
          ...
-         let mocked_msg_info = testing::message_info(&mocked_addr, &[]);
+         let mocked_msg_info = testing::mock_info(&mocked_addr.to_string(), &[]);
-         let minter = mocked_deps_mut
-             .api
-             .addr_humanize(&CanonicalAddr::from("minter".as_bytes()))
-             .expect("Failed to create minter address");
+         let minter = Addr::unchecked("minter");
-         let instantiate_msg = InstantiateMsg {
-             minter: minter.to_string(),
-         };
+         let instantiate_msg = simple_instantiate_msg(minter.to_string());
          ...
-         assert_eq!(contract_result.unwrap(), Response::default());
+         assert_eq!(
+             contract_result.unwrap(),
+             Response::default()
+                 .add_attribute("minter", "minter")
+                 .add_attribute("creator", "addr")
+         );
          ...
      }
  }

Note how:

  • Changes look mostly cosmetic.
  • There is no change to the assertion on MINTER other than now the use statement:
    • Refers to cw721::state::MINTER,
    • Instead of crate::state::MINTER.
  • The assertion on MINTER is unchanged because it uses .assert_owner, which is found in the ownable library, whether you import it yourself, or cw721 does.
  • The NFT library does not create an elegantly separate event, instead it adds straight attributes to the response. This behaviour depends on the library you use.

Execute

Here, you have to bring the same changes to the arrange part, then verify that the data was saved by using the library. It is a bit more involved.

The information about NFTs is stored in a map named .nft_info, and because of your choices here, the values stored are deserialized to the type NftInfo<Option<Empty>>. You access the map with Cw721EmptyExtensions::default().config.nft_info.

src/contract.rs
  ...
  mod tests {
      ...

      #[test]
      fn test_execute() {
          ...
-         let minter = mocked_deps_mut
-             .api
-             .addr_humanize(&CanonicalAddr::from("minter".as_bytes()))
-             .expect("Failed to create minter address");
+         let minter = Addr::unchecked("minter");
          let _ = super::instantiate(
              mocked_deps_mut.as_mut(),
              mocked_env.to_owned(),
-             testing::message_info(&mocked_addr, &[]),
+             testing::mock_info(&mocked_addr.to_string(), &[]),
-             InstantiateMsg {
-                 minter: minter.to_string(),
-             },
+             simple_instantiate_msg(minter.to_string()),
          )
          ...
-         let mocked_msg_info = testing::message_info(&minter, &[]);
+         let mocked_msg_info = testing::mock_info(&minter.to_string(), &[]);
          ...
-         let execute_msg = ExecuteMsg::Register {
-             name: name.clone(),
-             owner: owner.to_owned(),
+         let execute_msg = ExecuteMsg::Mint {
+             token_id: name.to_owned(),
+             owner: owner.to_string(),
+             token_uri: None,
+             extension: None,
          };
          ...
-         let expected_event = Event::new("name-register")
-             .add_attribute("name", name.to_owned())
-             .add_attribute("owner", owner.to_string());
-         let expected_response = Response::default().add_event(expected_event);
+         let expected_response = Response::default()
+             .add_attribute("action", "mint")
+             .add_attribute("minter", "minter")
+             .add_attribute("owner", "owner")
+             .add_attribute("token_id", "alice");
          ...
-         assert!(NAME_RESOLVER.has(mocked_deps_mut.as_ref().storage, name.as_bytes()));
+         assert!(Cw721EmptyExtensions::default()
+             .config
+             .nft_info
+             .has(mocked_deps_mut.as_ref().storage, name.as_str()));
-         let stored = NAME_RESOLVER.load(mocked_deps_mut.as_ref().storage, name.as_bytes());
+         let stored = Cw721EmptyExtensions::default()
+             .config
+             .nft_info
+             .load(mocked_deps_mut.as_ref().storage, name.as_str());
          assert!(stored.is_ok());
          assert_eq!(
              stored.unwrap(),
-             NameRecord { owner: owner }
+             NftInfo {
+                 owner: owner,
+                 approvals: [].to_vec(),
+                 token_uri: None,
+                 extension: None,
+             }
          );
      }
  }

Note how:

  • The Register message is now Mint.
  • Here too, there is no separate event but the attributes are added to the main result.
  • Previously you accessed the storage map with NAME_RESOLVER, now you achieve the same be digging a bit to nft_info.
  • The Mint message and the stored object both mention token_uri and extension as None. That's a result of your choice of picking Cw721EmptyExtensions.

Query

To test the query, you need to retrace both the instantiate and execute steps.

src/contract.rs
  ...
  mod tests {
      ...

      #[test]
      fn test_query() {
          ...
-         let minter = mocked_deps_mut
-             .api
-             .addr_humanize(&CanonicalAddr::from("minter".as_bytes()))
-             .expect("Failed to create minter address");
+         let minter = Addr::unchecked("minter");
          let _ = super::instantiate(
              mocked_deps_mut.as_mut(),
              mocked_env.to_owned(),
-             testing::message_info(&mocked_addr, &[]),
+             testing::mock_info(&mocked_addr.to_string(), &[]),
-             InstantiateMsg {
-                 minter: minter.to_string(),
-             },
+             simple_instantiate_msg(minter.to_string()),
          )
          ...
-         let mocked_msg_info = testing::message_info(&minter, &[]);
+         let mocked_msg_info = testing::mock_info(&minter.to_string(), &[]);
+         let execute_msg = ExecuteMsg::Mint {
+             token_id: name.to_owned(),
+             owner: mocked_addr.to_string(),
+             token_uri: None,
+             extension: None,
+         };
          ...
-         let _ = super::execute_register(
+         let _ = super::execute(
              mocked_deps_mut.as_mut(),
+             mocked_env.to_owned(),
              mocked_msg_info,
-             name.clone(),
-             &mocked_addr,
+             execute_msg,
          );
          ...
-         let query_msg = QueryMsg::ResolveRecord { name };
+         let query_msg = QueryMsg::OwnerOf {
+             token_id: name,
+             include_expired: None,
+         };
          ...
-         let expected_response = format!(r#"{{"address":"{mocked_addr_value}"}}"#);
+         let expected_response = format!(r#"{{"owner":"{mocked_addr_value}","approvals":[]}}"#);
-         let expected = Binary::new(expected_response.as_bytes().to_vec());
+         let expected = Binary::from(expected_response.as_bytes());
          ...
      }
  }

Note how:

  • QueryMsg now offers a long list of variants, of which the most succinct for the test is OwnerOf.

Update your mocked app tests

Here, as for the unit tests you need to:

  • Adjust for the change of CosmWasm version from 2 to 1.
  • Change how your messages are built.
  • Change the event and storage checks with newly appropriate ones, including the storage keys.

The deploy helper

This function exists to assist you in proper tests. Without surprise, it changes to reflect the current status:

tests/contract.rs
  ...
- type ContractAddr = Addr;
- type MinterAddr = Addr;

- fn instantiate_nameservice(mock_app: &mut App) -> (u64, ContractAddr, MinterAddr) {
+ fn instantiate_nameservice(mock_app: &mut App) -> (u64, Addr) {
      ...
-     let minter = mock_app
-         .api()
-         .addr_humanize(&CanonicalAddr::from("minter".as_bytes()))
-         .unwrap();
      return (
          nameservice_code_id,
          mock_app
              .instantiate_contract(
                  ...
                  &InstantiateMsg {
+                     name: "my names".to_owned(),
+                     symbol: "MYN".to_owned(),
+                     creator: None,
-                     minter: minter.to_string(),
+                     minter: Some("minter".to_owned()),
+                     collection_info_extension: None,
+                     withdraw_address: None,
                  },
                  ...
              )
              .expect("Failed to instantiate nameservice"),
-         minter
      );
  }

Note that:

  • You no longer need to disambiguate the 2 returned Addr.
  • As in the unit tests, the InstantiateMsg is longer but full of dummy data or Nones.

Execute

The difficulty here is to get access to the right values in storage when accessing it directly.

tests/contract.rs
- use cosmwasm_std::{Addr, Api, CanonicalAddr, Event};
+ use cosmwasm_std::{Addr, Event, StdError, Storage};
+ use cw721::msg::OwnerOfResponse;
  use cw_multi_test::{App, ContractWrapper, Executor};
  use cw_my_nameservice::{
      contract::{execute, instantiate, query},
-     msg::{ExecuteMsg, InstantiateMsg, QueryMsg, ResolveRecordResponse},
+     msg::{ExecuteMsg, InstantiateMsg, QueryMsg},
  };

  ...

  fn test_register() {
      ...
-     let (_, contract_addr, minter) = instantiate_nameservice(&mut mock_app);
+     let (_, contract_addr) = instantiate_nameservice(&mut mock_app);
      ...
-     let register_msg = ExecuteMsg::Register {
+     let register_msg = ExecuteMsg::Mint {
-         name: name_alice.to_owned(),
+         token_id: name_alice.to_owned(),
-         owner: owner_addr.to_owned(),
+         owner: owner_addr.to_string(),
+         extension: None,
+         token_uri: None,
      };
      ...
      let result = mock_app.execute_contract(
-         minter,
+         Addr::unchecked("minter"),
          ...
      );
      ...
-     let expected_event = Event::new("wasm-name-register")
+     let expected_event = Event::new("wasm")
+         .add_attribute("_contract_address", "contract0".to_owned())
+         .add_attribute("action", "mint".to_owned())
+         .add_attribute("minter", "minter".to_owned())
-         .add_attribute("name", name_alice.to_owned())
-         .add_attribute("owner", owner_addr_value.to_owned());
+         .add_attribute("owner", owner_addr_value.to_owned())
+         .add_attribute("token_id", name_alice.to_owned());
      ...
+     // Global storage
+     let expected_key_main =
+         format!("\0\u{4}wasm\0\u{17}contract_data/contract0\0\u{6}tokens{name_alice}",);
+     let stored_addr_bytes = mock_app
+         .storage()
+         .get(expected_key_main.as_bytes())
+         .expect("Failed to load from name alice");
+     let stored_addr = String::from_utf8(stored_addr_bytes).unwrap();
+     assert_eq!(
+         stored_addr,
+         format!(
+             r#"{{"owner":"{owner_addr_value}","approvals":[],"token_uri":null,"extension":null}}"#
+         )
+     );
+     // Storage local to contract
      let stored_addr_bytes = mock_app
          .contract_storage(&contract_addr)
-         .get(format!("\0\rname_resolver{name_alice}").as_bytes())
+         .get(format!("\0\u{6}tokens{name_alice}").as_bytes())
          .expect("Failed to load from name alice");
      ...
      assert_eq!(
          stored_addr,
-         format!(r#"{{"owner":"{owner_addr_value}"}}"#)
+         format!(
+             r#"{{"owner":"{owner_addr_value}","approvals":[],"token_uri":null,"extension":null}}"#
+         )
      );
  }

Note how:

  • The attributes are now piled int the wasm event, which is always there as it is added by the CosmWasm module, or the mocked app as seen here.
  • You confirm that you can access the NFT info with a global storage key.
  • You also check that you can access the same info from a key relative to the smart contract's storage area. That assists in visualizing what is taking place within the library.
  • These long keys can be explained:
    • "\0\u{4}wasm" is the prefix of all storage handled by CosmWasm.
    • "\0\u{17}contract_data/" is the next prefix that CosmWasm reserves to store all smart contract data.
    • "contract0" is the next prefix that CosmWasm uses for all the storage of your smart contract being tested. 0 is the instance id, this strictly incrementing number.
    • "\0\u{6}tokens" is the next prefix that the library uses to store nft_info, and where "\0\u{6}" identifies an IndexedMap.
    • "alice", the last element, is the key of the value in the indexed map.

Query(s)

To be able to get to the query, you have to retrace the same steps as when testing the execution. Then it is only a matter of changing the QueryMsg types.

tests/contract.rs

  fn test_query() {
      ...
-     let (_, contract_addr, minter) = instantiate_nameservice(&mut mock_app);
+     let (_, contract_addr) = instantiate_nameservice(&mut mock_app);
      ...
-     let register_msg = ExecuteMsg::Register {
+     let register_msg = ExecuteMsg::Mint {
-         name: name_alice.to_owned(),
+         token_id: name_alice.to_owned(),
-         owner: owner_addr.to_owned(),
+         owner: owner_addr.to_string(),
+         extension: None,
+         token_uri: None,
      };
      ...
      let _ = mock_app
          .execute_contract(
-             minter,
+             Addr::unchecked("minter"),
              ...
          )
          .expect("Failed to register alice");
-     let resolve_record_query_msg = QueryMsg::ResolveRecord {
+     let resolve_record_query_msg = QueryMsg::OwnerOf {
-         name: name_alice.to_owned(),
+         token_id: name_alice.to_owned(),
+         include_expired: None,
+     };
      ...
      let result = mock_app
          .wrap()
-         .query_wasm_smart::<ResolveRecordResponse>(&contract_addr, &resolve_record_query_msg);
+         .query_wasm_smart::<OwnerOfResponse>(&contract_addr, &resolve_record_query_msg);
      ...
      assert_eq!(
          result.unwrap(),
-         ResolveRecordResponse {
+         OwnerOfResponse {
-             address: Some(owner_addr.to_string())
+             owner: owner_addr.to_string(),
+             approvals: [].to_vec(),
          }
      );
  }

  fn test_query_empty() {
      ...
-     let (_, contract_addr, _) = instantiate_nameservice(&mut mock_app);
+     let (_, contract_addr) = instantiate_nameservice(&mut mock_app);
      let name_alice = "alice".to_owned();
-     let resolve_record_query_msg = QueryMsg::ResolveRecord {
+     let resolve_record_query_msg = QueryMsg::OwnerOf {
-         name: name_alice.to_owned(),
+         token_id: name_alice.to_owned(),
+         include_expired: None,
      };
      ...
      let result = mock_app
          .wrap()
-         .query_wasm_smart::<ResolveRecordResponse>(&contract_addr, &resolve_record_query_msg);
+         .query_wasm_smart::<OwnerOfResponse>(&contract_addr, &resolve_record_query_msg);
      ...
-     assert!(result.is_ok(), "Failed to query alice name");
+     assert!(result.is_err(), "There was an unexpected value");
-     assert_eq!(result.unwrap(), ResolveRecordResponse { address: None })
+     assert_eq!(result.unwrap_err(), StdError::GenericErr { 
+         msg: "Querier contract error: type: cw721::state::NftInfo<core::option::Option<cosmwasm_std::results::empty::Empty>>; key: [00, 06, 74, 6F, 6B, 65, 6E, 73, 61, 6C, 69, 63, 65] not found".to_owned(),
+     });
  }

Note that:

  • The new response type is OwnerOfResponse.
  • If a record is missing it returns an error instead of a None as you did earlier.
  • Said error is quite verbose and cannot really be guessed. Its list of bytes represents "\0\u{6}tokensalice".

Conclusion

Now that you delegate to the NFT library, it could be worhwhile to test that the other features you expect are present, such as approvals and transfers. It would be good to also confirm that the minter indeed gatekeeps the minting call. This is left as an exercise.

Exercise progression

At this stage, you should have something similar to the add-nft-library branch, with this as the diff.

You have added the NFT library to increase your compatibility with other smart contracts. In the next section, you learn how to have cross-contract communication.