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.
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:
- Change the dependencies.
- Decide how much of the library you are going to use.
- Decide what to still declare in storage and what to delegate.
- Ditto for messages.
- Update the smart contract handling of messages.
- Update the tests.
Add the dependency
cargo add cw721 --git https://github.com/public-awesome/cw-nfts --tag "v0.19.0"
docker run --rm -it \
-v $(pwd):/root/ -w /root \
rust:1.80.1 \
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
andcw-storage-plus
libraries come withcw721
, 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 bycw721
: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:
- 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:
- 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:
- 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.
- 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 isMint
, 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:
...
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.
...
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 theuse
statement:- Refers to
cw721::state::MINTER
, - Instead of
crate::state::MINTER
.
- Refers to
- The assertion on
MINTER
is unchanged because it uses.assert_owner
, which is found in theownable
library, whether you import it yourself, orcw721
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
.
...
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 nowMint
. - 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 tonft_info
. - The
Mint
message and thestored
object both mentiontoken_uri
andextension
asNone
. That's a result of your choice of pickingCw721EmptyExtensions
.
Query
To test the query, you need to retrace both the instantiate and execute steps.
...
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 isOwnerOf
.
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:
...
- 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 orNone
s.
Execute
The difficulty here is to get access to the right values in storage when accessing it directly.
- 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 storenft_info
, and where"\0\u{6}"
identifies anIndexedMap
."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.
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.
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.