How to Code Multi-Contract Interactions (InterWasm DEV #2)
Querying & Executing Other Contracts from Your CosmWasm Smart Contract — Including Triggering Actions by Sending NFTs {ATOM, OSMO, SCRT, JUNO, LUNA}
One of the most powerful abilities of smart contracts is their ability to interact with other contracts.
Inter-contract communication enables flexibility and composability.
Your app can have multiple contracts.
Whether 1) you’re forced to use many contracts to meet size constraints, 2) you’re separating different domains or security concerns, 3) your contracts have different lifetimes or scopes (such as a long-term factory contract that deploys contracts intended for shorter-term use), or some other reason, you might be building an app with several contracts.
Or, you might want to query and interact with the contracts of *other* applications.
You may need to get oracle information your app needs. Or, you may want your contract to use DeFi products exposed by other contracts, such as LPs.
For my recent smart contract writing work at Deviants, I had to have three main inter-contract actions:
- I needed an NFT to trigger an action when it was sent to a recipient contract.
- I needed to query information from one contract to another.
- I needed one smart contract to command another smart contract to mint an NFT.
These are the primary kinds of contract-to-contract interactions you’ll be doing in your contract writing, as long as we categorize cw20 token triggers as similar to cw721 NFT triggers. IBC (Inter-Blockchain Communication) transactions require a variation on #3 which we’ll discuss at the end.
Let’s take these 3 in order.
1. Triggering a Smart Contract when it Receives an NFT
The cw721 standard includes receiver code that demonstrates how to trigger a contract action when it receives an NFT.
The Sending Contract
The sending NFT code is boilerplate. Your NFT contract — the cw721 contract that governs the NFT and keeps track of things like NFT metadata and ownership —should already have this code in place without you needing to add it.
It includes:
use cw721::Cw721ReceiveMsg;
Which allows your contract to understand how to create and understand these kinds of messages:
// sending contract msg.rs pub struct Cw721ReceiveMsg {
pub sender: String,
pub token_id: String,
pub msg: Binary,
}
In this case, sender
refers to the human sender (NFT owner) address, not to the sending contract (NFT contract). The address of this sending contract will be included, of course, but we’ll access it using info.sender
, not the sender
in this message.
msg
is an additional arbitrary message. You can put a struct
of any type containing any information here, as long as you turn it to the Binary
type using to_binary(&<my_custom_struct>)?
With our types in place, let’s look at how the code actually creates and adds this message to the transaction.
In the send_nft()
function, which handles SendNft
messages, we see a new Cw721ReceiveMsg
created and added to the Response
. We’ll do this again in the future — add a message to our response — whenever we want a contract to trigger another one:
// sending contract contract.rs or execute.rsself.transfer_nft(deps, &env, &info, &contract, &token_id, true)?;
// ^ this changes the NFT ownership, but doesn't trigger anythinglet send = Cw721ReceiveMsg {
sender: info.sender.to_string(),
token_id: token_id.clone(),
msg,
};// Send message
Ok(Response::new()
.add_message(send.into_cosmos_msg(contract.clone())?)
We won’t spend much time here because, again, this is boilerplate: included already in cw721 contracts.
But you can see that a Cw721ReceiveMsg
is created and attached. Now, the receiving contract needs to be able to receive and handle the message.
The Receiving Contract
First of all, I created an execute message that takes the above message as its input. In the enum ExecuteMsg
(usually in msg.rs):
// receiving contract msg.rs#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ExecuteMsg {
...
ReceiveNft(Cw721ReceiveMsg),
}
Of course, the Cw721ReceiveMsg
type will need to be interpretable, so I had to include a definition that matches the one the sending contract used to create and serialize it.
For this, copy and paste from the receiver.rs file in the cw721 standard, or from below:
// receiving contract msg.rs#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)]
#[serde(rename_all = "snake_case")]
pub struct Cw721ReceiveMsg {
pub sender: String,
pub token_id: String,
pub msg: Binary,
}
Optional: I also wanted to be able to easily print out this whole message with something like println!("The message is {:?}", msg);
so I added:
// receiving contract msg.rsimpl fmt::Display for Cw721ReceiveMsg {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"sender:{} token_id:{} msg:{}",
self.sender,
self.token_id,
self.msg.to_string()
)
}
}
Now, as usual with all ExecuteMsgs, I add a handler to my execute()
entry point function’s main match
statement:
// receiving contract execute.rs (or contract.rs)ExecuteMsg::ReceiveNft(msg) => try_receive_nft(env, deps, info, msg)
This passes off execution to a try_receive_nft()
function. I can do whatever we need in this function.
Here, I extract some necessary data and then pass execution off to another function called
do_stuff_with_nft()
. In my case, this function was calledmint_cards()
and sent more NFTs back to thehuman_sender
. More on that later.
// receiving contract execute.rs (or contract.rs)fn try_receive_nft(
env: Env;
mut deps: DepsMut,
info: MessageInfo,
nft_msg: Cw721ReceiveMsg,
) -> Result<Response, ContractError> {
verify_authorized_nft_contract(deps.storage, &info.sender)?;
let state = STATE.load(deps.storage)?;
if state.opening_paused {
return Err(ContractError::OpeningPaused {});
}
let this_nft_id = nft_msg.token_id;
let human_sender = nft_msg.sender; // do stuff here, which responds with receive_nft_response
let receive_nft_response = do_stuff_with_nft(
env,
deps.branch(),
&info.sender,
this_pack_id,
human_sender,
)?; Ok(receive_nft_response)
};
More on verify_authorized_nft_contract()
below. First, here’s the information I can get about the incoming NFT message:
- any arbitrary information can be packed in the Cw721ReceiveMsg’s
msg
variable. In the code below, I don’t use this, but I could with something likefrom_binary(&nft_msg.msg)?
if the sending contract added some information here. - the “human sender” is in the
Cw721ReceiveMsg
. Here, I get the human sender withnft_msg.sender
. (This could also be a contract that owned the NFT.) It’s whatever address previously owned the NFT and sent it to this contract. You’ll usually need this, since there’s probably some message, funds, status, or NFT you’ll need to give to this sender. - the unique
token_id
that identifies this exact NFT is also in theCw721ReceiveMsg
. I get it here withnft_msg.token_id
- the NFT contract address for the sent NFT is stored in
info.sender
. This importantly cannot be altered.
It’s critical to check that the NFT contract sending an NFT is a valid, expected contract address.
This way, your contract cannot be triggered by fake NFTs pretending to be real ones. I do this with a special function:
// receiving contract execute.rs (or contract.rs)fn verify_authorized_nft_contract(
storage: &mut dyn Storage,
address: &Addr,
) -> Result<(), ContractError> {
#[cfg(test)]
if address.to_string() == "creator".to_string() {
return Ok(());
} #[cfg(not(test))]
let nft_contracts = NFTCONTRACTS.load(storage)?;
for n in 0..nft_contracts.len() {
if &nft_contracts[n] == address {
return Ok(());
}
}
return Err(ContractError::InvalidNFTContract {});
}
Several notes here:
- this function returns
Ok(())
if the contract is authorized to send in triggering NFTs. Otherwise, it throws an error. - for primitive multi-environment support, I used
#[cfg(test)]
to allow an accountcreator
to pass as a pack contract during unit tests. I could add some more robust code, if I was using a local multi-test package or other environments, but it suited my needs here. - notice that I don’t just check a hardcoded address with
if address == "terra1someaddresshere"
. You could do that! But this contract might need to add additional authorized contracts later, soNFTCONTRACTS
was built with a function that allows theADMIN
account to add more authorized contracts as needed. - as usual,
ContractError::InvalidNFTContract
is defined inerror.rs
. (A quick workaround if you’re just experimenting is something likereturn Err(StdError::generic_err(“Invalid NFT contract!”));
)
// error.rs #[error(“invalid NFT contract”)]
InvalidNFTContract {},
Now this contract is ready to accept NFTs from any authorized contract listed in NFTCONTRACTS
and pass the information to do_stuff_with_nft()
so that it can appropriately act — for example, by rewarding the sender with a newly transformed NFT, or an opened pack of NFTs.
2. Querying a Smart Contract from Another Contract
In my case, the contract opened a pack of NFTs. This means that it:
- Received a “pack” NFT,
- Selected some random cards, with various constraints,
- Ordered a different contract, the card cw721 contract, to mint the cards under the ownership of the
human_sender
- Responded with information about the cards minted
However, since our card contract primarily deals with NFTs by ids (which are auto-assigned when card designs are created) and not by card names, I might need to be able to “look up” card ids by card name in order to be sure that the correct cards are being minted, so that I can form my mint_nft()
command with the right parameters.
Our card cw721 contract had a query for this purpose, CollectionByName
. (This card contract refers to one kind of card as a “collection,” and one collection can have multiple NFTs. Stay tuned for more updates on this upgraded NFT contract.)
// msg.rs in contract answering the query
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum QueryMsg {
...
CollectionByName {
collection_name: String,
}
}
This CollectionByName
query returns response containing a collection_id: String
:
// msg.rs in contract answering the query#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)]
pub struct CollectionIdResponse {
pub collection_id: String,
}
Calling this query from somewhere like ExtraTerrestrial Finder is easy. Even from Terra Finder, it’s a simple matter of:
{ "collection_by_name": { "collection_name": "SuperCard9000" } }
But how can I query from inside a different smart contract?
First, I need my contract to be able to serialize and deserialize — to create and to understand — the query message and its response.
So, I put the very same CollectionIdResponse
and CollectionByName
items into my msg.rs
, so that I could work with them in my querying contract.
I didn’t put the CollectionByName
item into my main QueryMsg
enum, of course — this isn’t a query that my contract needs to receive and answer, just one that it needs to know how to send. Instead, I created a new enum specifically for this external query and called it CollectablesQueryMsg
.
So, a section of my sending contract’s msg.rs
file looked very similar to a section of my receiving contract’s msg.rs
file:
// msg.rs in contract *asking* the query#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum CollectablesQueryMsg {
...
CollectionByName {
collection_name: String,
}
}#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)]
pub struct CollectionIdResponse {
pub collection_id: String,
}
Notice again that my card 721 contract uses the term collection
— you might just have something like NFTQueryMsg
, NFTIdResponse
, and nft_id
.
Then, I use these structures to form and send a query to another contract and to interpret the response:
fn get_collection_id_by_name(
env: Env,
deps: Deps,
collection_name: String,
) -> Result<String, ContractError> {
#[cfg(test)]
if env.block.height == 12345 {
//the mock env block
Ok(0)
}
#[cfg(not(test))]
let state = STATE.load(deps.storage)?;
let query_msg: CollectablesQueryMsg =
CollectablesQueryMsg::CollectionByName(CollectionByNameMsg {
collection_name: name,
});
let query_response: CollectionIdResponse =
deps.querier.query(&QueryRequest::Wasm(WasmQuery::Smart {
contract_addr: state.card_contract_address.to_string(),
msg: to_binary(&query_msg)?,
}))?; Ok(query_response.collection_id)
}
Note that our contract stores the proper address in state.card_contract_address
. Otherwise the VM wouldn't know what contract it was supposed to query!
With this helper function done, I can now write something like let id = get_collection_id_by_name(env, deps, "SuperCard9000")?;
in order to query the NFT contract and get the proper id.
In the real contract, I then needed to parse this
String
into au32
.
3. Executing a Smart Contract from Another Contract
Querying another contract is useful, but what if we want to tell another contract to execute something?
This is definitely more involved, but the principles are the same. To repeat the above headline:
First, I need my contract to be able to serialize and deserialize — to create and to understand — the execute message and its response.
#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)]
#[serde(rename_all = "snake_case")]
pub enum CollectablesExecuteMsg {
QuickMint(QuickMintMsg),
}#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
pub struct QuickMintMsg {
pub collection_id: u64,
pub num_to_mint: u64,
pub owner: String,
pub token_prefix: String,
}
If you’re minting an NFT, of course, your message might look a lot different. But the content of the message isn’t what we care about in this article — as long as it matches the ExecuteMsg
in the contract you’re trying to interact with!
One interesting quirk is that you might need to alphabetize.
Yes, it sounds strange.
But while Terra Station alphabetizes for you, and command line apps alphabetize for you, at this point you might encounter strange errors if the parameters aren’t alphabetized from within a smart contract.
In order to not run into this error and beat my head against a wall while I try to figure it out, I just always keep things alphabetical when dealing with inter-contract execute messages.
Now we need to be able to wrap our message (in my case, QuickMintMsg) into the proper WasmMsg.
Unlike with our QueryMsg
in part 2 of the article, our ExecuteMsg
needs to be accompanied by additional information. To do so, let’s add some functionality to our QuickMintMsg
in our sending contract so that it can format itself properly:
impl QuickMintMsg {
/// serializes the message
pub fn into_binary(self) -> StdResult<Binary> {
let msg = CollectablesExecuteMsg::QuickMint(self);
to_binary(&msg)
} /// creates a cosmos_msg sending this struct to the named contract
pub fn into_cosmos_msg<
T: Into<String>,
C
>(self, contract_addr: T) -> StdResult<CosmosMsg<C>> where C: Clone + std::fmt::Debug + PartialEq + JsonSchema, {
let msg = self.into_binary()?;
let execute = WasmMsg::Execute {
contract_addr: contract_addr.into(),
msg,
funds: vec![],
};
Ok(execute.into())
}
}
These two little functions let us take a QuickMintMsg
that we create, let’s call it msg
, and format it properly by simply calling msg.into_cosmos_msg(<target contract address>);
So now, in my contract, I can:
// create the message
let core_msg = QuickMintMsg {
collection_id: collection_id.into(),
num_to_mint: 1u64,
owner: human_sender.clone(),
token_prefix: prefix.clone(),|
};// use our impl'ed functions to make it a serialized CosmosMsg
let processed_msg = core_msg
.clone()
.into_cosmos_msg(state.card_contract_address.to_string())?;// attach the message to the response so that it gets sent
OK(Response::new()
.add_message(processed_msg))
Note: if this QuickMintMsg action fails, the entire transaction will fail.
If you don’t want this behavior, use .add_submessage
instead. Then, the target contract should send a reply: and the “parent” transaction’s success will not, by default, depend on the child transaction.
A SubMsg
is used for IBC and might have other use cases. It wraps a CosmosMsg
and has several callback options for handling what happens:
pub struct SubMsg<T = Empty>
where
T: Clone + fmt::Debug + PartialEq + JsonSchema,
{
pub id: u64,
pub msg: CosmosMsg<T>,
pub gas_limit: Option<u64>,
pub reply_on: ReplyOn,
}
pub enum ReplyOn {
/// Always perform a callback after SubMsg is processed
Always,
/// Only callback if SubMsg returned an error, no callback on success case
Error,
/// Only callback if SubMsg was successful, no callback on error case
Success,
}
In my use case, I didn’t need to use a SubMsg
, and instead just used .add_message(<CosmosMsg>)
. Read more about submessages and more in Ethan Frey’s article:
Testing this behavior was tricky, as multi-contract testing is still early and buggy.
On the whole, I had to deploy the various contracts to testnet in order to test interactions. Read the first article in the InterWasm DEV series for more information:
Peter Keay is Co-Founder at Obi.Money. InterWasm DEV is a series of topical posts on building apps on Cosmos and other blockchains using Rust or Move smart contracts.