Unleashing Interchain NFT Utilities with cw-ics721 and Callbacks: A Comprehensive Implementation Guide
This is a guest post by Ark Protocol founder @Mr-T. For more about the author, please read his bio at the bottom of this post. This technical deep dive aims to inspire developers across the ecosystem to explore and implement cw-ics721
, enhancing the functionality and reach of NFTs.
Introduction
The Interchain ecosystem is rapidly evolving, with Interchain capabilities transforming how assets move across different blockchains. Ark Protocol's cw-ics721
provides a robust solution for Interchain NFT transfers, leveraging the IBC protocol to enable seamless InterChain interactions. This article delves into the technical implementation and use cases of cw-ics721
, showcasing its potential to revolutionize NFT utilities.
About Ark Protocol
Ark Protocol is dedicated to building Interchain NFT utilities, enabling seamless NFT transfers and access to utilities across multiple blockchains. By leveraging the Inter-Blockchain Communication Protocol (IBC), Ark Protocol aims to create a unified NFT ecosystem where collections can be accessed and utilized on any chain using CosmWasm, at any time. As of now, Ark Protocol has deployed Interchain contracts on 7+ chains. Our mission is to empower the NFT community by providing secure, efficient, and innovative solutions for Interchain interactions across all CosmWasm-based chains.
Ark's Mission
Ark is building an Interchain NFT Hub. Technically, this means transitioning NFT utilities from a local, single-chain to a global and Interchain level (like transfers, staking, snapshots, launchpads, marketplace, etc.).
Ark's team is one of the main contributors for cw-ics721
and cw-nfts
. Recent utilities we have provided are:
- ICS 721
- Interchain transfers
- Outgoing and incoming proxies for additional security (e.g., whitelisting IBC channels)
- Optional receive and ack callbacks
cw-nfts
- cw721-expiration: For issuing time-based subscriptions and services
- Upcoming major v0.19 release
- Main logic moved to
cw721
package for better re-use - Distinction between
creator
andminter
- NEW
CollectionInfo
incw721
package - NEW utility:
UpdateNftInfo
andUpdateCollectionInfo
msg - More Interchain utilities coming soon
- Interchain launchpad
- Interchain marketplace
cw-ics721
v2 (onchain metadata, royalties, single-hop-only transfers, etc.)
Understanding cw-ics721
The cw-ics721
standard facilitates NFT transfers between chains by locking (aka escrowing
) the original NFT on the source chain and minting a new NFT (aka debt voucher
) on the target chain. If the NFT is returned, it gets burned on the target chain and unescrowed/transferred back to the recipient on the source chain. This process ensures that NFTs can seamlessly move across different blockchain ecosystems while maintaining their unique properties and metadata.
Understanding Proxy Contracts for Security Considerations
Ark provides additional security measures to prevent possible exploits and malicious attacks on ics721
:
- Outgoing and Incoming Proxy Contracts: Secure the transfer process with rate limits, whitelisting channels, collections, and code hashes, preventing unauthorized transfers and vector attacks.
- The example uses a simple rate limiter for outgoing proxy.
cw-ics721
managed by Ark Protocol uses a more advanced and secure outgoing proxy. - Incoming proxies secure
ics721
across all Cosmos chains (and not only CosmWasm-based chains) from malicious or compromised chains. - Multisigs: All InterChain contracts use multisig wallets for managing contract ownership and administration, ensuring a higher level of security.
- Ark plans on transitioning to DAO-managed InterChain contracts.
Case Study
This process involves minting an NFT, transferring it between Osmosis and Stargaze, and demonstrating the callback mechanism to update metadata seamlessly. This is a full example demonstrating how cw721
interacts with cw-ics721
, incoming, and outgoing proxies. The demo shows how an NFT and its metadata are affected using callbacks during Interchain (ics721
) transfers:
1. Minting an NFT PFP on Osmosis:
A minted NFT PFP on Osmosis (source/home chain) looks like this:
2. Transferring NFT to Stargaze:
After transferring the NFT to Stargaze, it is escrowed on Osmosis, and its metadata is updated.
3. Transferring Back to Osmosis:
When transferring an NFT back to the home chain, it is burned on Stargaze and reset on Osmosis.
Implementation Walkthrough
In this post, we focus on the callbacks to avoid overwhelming the article. For all other code snippets, links are provided to examine the entire workflow in detail.
Setup and Deployment
Follow the SETUP.md for deploying these contracts on Osmosis and Stargaze testnet:
cw721_base.wasm
: Deploys two collection contracts- Passport collection: Used for
ics721
NFT transfers between Osmosis and Stargaze - POAP collection: Mints a POAP NFT on the target chain for the recipient, as a reward, on each
ics721
transfer using callbacks. cw_ics721_arkite_passport.wasm
(akaArkite
contract)- The contract where a Passport NFT is sent.
- Triggers
cw-ics721
transfer by passing an NFT to outgoing proxy contract and attaching callbacks in the memo field. cw_ics721_outgoing_proxy_rate_limit.wasm
- Validates incoming NFTs to ensure only legitimate transfers occur.
- Forwards NFT to
cw-ics721
ics721_base.wasm
- Sends IBC packet, including NFT on-chain data, to the counterpart contract,
cw-ics721
, on the target chain cw_ics721_incoming_proxy_base.wasm
- Triggered by the counterpart contract
cw-ics721
- Validates incoming channels are whitelisted by the incoming proxy, ensuring only legitimate transfers occur.
Arkite Messages
The Arkite contract provides the following messages:
1
2
3
4
5
6
7
8
9
10
11
pub enum ExecuteMsg {
Mint {},
ReceiveNft(Cw721ReceiveMsg),
CounterPartyContract {
addr: String,
},
/// Ack callback on source chain
Ics721AckCallback(Ics721AckCallbackMsg),
/// Receive callback on target chain, NOTE: if this fails, the transfer will fail and NFT is reverted back to the sender
Ics721ReceiveCallback(Ics721ReceiveCallbackMsg),
}
Minting a Passport NFT
ExecuteMsg::Mint
can be triggered using ./scripts/mint.sh osmosis. This mint message executes cw721_base::msg::ExecuteMsg::Mint to mint an NFT containing metadata with four traits:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
fn create_mint_msg(deps: DepsMut, cw721: Addr, owner: String) -> Result<SubMsg, ContractError> {
...
let default_token_uri = DEFAULT_TOKEN_URI.load(deps.storage)?;
let escrowed_token_uri = ESCROWED_TOKEN_URI.load(deps.storage)?;
let transferred_token_uri = TRANSFERRED_TOKEN_URI.load(deps.storage)?;
let trait_token_uri = Trait {
display_type: None,
trait_type: "token_uri".to_string(),
value: default_token_uri.clone(),
};
let trait_default_uri = Trait {
display_type: None,
trait_type: "default_uri".to_string(),
value: default_token_uri.clone(),
};
let trait_escrowed_uri = Trait {
display_type: None,
trait_type: "escrowed_uri".to_string(),
value: escrowed_token_uri.clone(),
};
let trait_transferred_uri = Trait {
display_type: None,
trait_type: "transferred_uri".to_string(),
value: transferred_token_uri.clone(),
};
let extension = Some(NftExtensionMsg {
image: Some(Some(default_token_uri.clone())),
attributes: Some(Some(vec![
trait_token_uri,
trait_default_uri,
trait_escrowed_uri,
trait_transferred_uri,
])),
..Default::default()
});
let mint_msg = WasmMsg::Execute {
contract_addr: cw721.to_string(),
msg: to_json_binary(&cw721_base::msg::ExecuteMsg::<
DefaultOptionalNftExtensionMsg,
DefaultOptionalCollectionExtensionMsg,
Empty,
>::Mint {
token_id: num_tokens.count.to_string(),
owner,
token_uri: Some(default_token_uri.clone()),
extension,
})?,
funds: vec![],
};
let sub_msg = SubMsg::reply_always(mint_msg, MINT_NFT_REPLY_ID);
Ok(sub_msg)
$CLI query wasm contract-state smart $ADDRCW721 '{"nftinfo":{"tokenid": "1"}}' --chain-id $CHAINID --node $CHAIN_NODE | jq
provides the following NFT details:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{
"data": {
"token_uri": "https://github.com/arkprotocol/cw-ics721-callback-example/raw/main/public/passport_osmosis01_home.png",
"extension": {
"image": "https://github.com/arkprotocol/cw-ics721-callback-example/raw/main/public/passport_osmosis01_home.png",
"image_data": null,
"external_url": null,
"description": null,
"name": null,
"attributes": [
{
"display_type": null,
"trait_type": "token_uri",
"value": "https://github.com/arkprotocol/cw-ics721-callback-example/raw/main/public/passport_osmosis01_home.png"
},
{
"display_type": null,
"trait_type": "default_uri",
"value": "https://github.com/arkprotocol/cw-ics721-callback-example/raw/main/public/passport_osmosis01_home.png"
},
{
"display_type": null,
"trait_type": "escrowed_uri",
"value": "https://github.com/arkprotocol/cw-ics721-callback-example/raw/main/public/passport_osmosis02_away.png"
},
{
"display_type": null,
"trait_type": "transferred_uri",
"value": "https://github.com/arkprotocol/cw-ics721-callback-example/raw/main/public/passport_osmosis03_transferred.png"
}
],
"background_color": null,
"animation_url": null,
"youtube_url": null
}
}
}
Please note that token_uri
on mint refers to the default passport_osmosis01_home.png
image. In the next step, token_uri
will be changed while executing Interchain transfer using callbacks.
Check minted NFT using Ark's UI here: https://testnet.arkprotocol.io/collections/CW721ADDRESS/NFTID
Transferring Passport NFT from Osmosis to Stargaze
NFT #1 can be transferred by executing this script: ./scripts/transfer.sh osmosis 1. Once the script is executed, the following workflow covers three main parts:
- Initialize Interchain transfer on Osmosis as source chain, and attaching receive and ack callbacks
- Receive NFT packet on Stargaze, target chain, for minting a Passport NFT (aka
debt voucher
), executing receive callback and sending anack
packet back to target chain. - Finally, processing
ack
packet on source chain and executing ack callback.
In this example the receive callback on target chain does 2 things:
- Changing image by updating the Passport NFT
- Passport receiver gets another NFT from POAP collection contract, as a reward for doing an InterChain transfer
The ack callback on source chain in return:
- Changes image, indicating Passport NFT has been escrowed by ICS721.
- In case of failure, undo and return NFT back to sender.
Initialize Interchain Transfer on Source Chain with Callbacks
Initializing an Interchain transfer involves these steps:
- Script triggers Interchain transfer
- Executes
cw721_base::msg::ExecuteMsg::SendNft
on the Passport collection contract - Attaches IbcOutgoingMsg data to
SendNft
IbcOutgoingMsg
attachment in the script can be found here.- Passport collection contract processing
SendNft
- Transfers NFT to Arkite contract
- Contract calls
ExecuteMsg::ReceiveNft
on Arkite contract - Attaches
IbcOutgoingMsg
data toReceiveNft
IbcOutgoingMsg
attachment in the contract can be found here.- Arkite contract processing
ReceiveNft
- Forwards/retransfers NFT to the outgoing proxy contract by calling
SendNFT
on the Passport collection and attaching modifiedIbcOutgoingMsg
with callbacks as memo execute_receive_nft()
main code is here- Passport collection contract processing
SendNft
- Same as above: transfer NFT ownership and call
ReceiveNft
on the outgoing proxy send_nft()
code is here.- Outgoing Proxy contract processing
ReceiveNft
- Validates rate limit, ensuring only legitimate transfers occur
- Transfers NFT ownership to the ics721 contract
- Calls
ProxyExecuteMsg::ReceiveNft(cw721::Cw721ReceiveMsg)
on the ics721 contract - Attaches
IbcOutgoingMsg
data toReceiveNft
execute_receive_nft()
main code is here- ICS721 contract processing
ReceiveNft
- Validates whether the sender is the outgoing proxy
- Validates the NFT is escrowed/owned by ics721
- Creates
NonFungibleTokenPacketData
containing collection and NFT data - Sends IBC message with
NonFungibleTokenPacketData
- Key logic here
For simplicity, please note that in step 1, the recipient for the Passport NFT is the Arkite contract on Stargaze, the target chain! This way, the Arkite contract, being the creator of the Passport collection, will be able to update the NFT data.
In step 3, on execute_receive_nft()
, it does five things:
- Checks whether the outgoing proxy is set in ics721
- Unwraps
IbcOutgoingMsg
- Creates
Ics721Memo
with receive and ack callbacks - Attaches
memo
field intoIbcOutgoingMsg
- Forwards and executes
SendNft
with modifiedIbcOutgoingMsg
Note: If an outgoing proxy address is set, then ics721 only accepts ReceiveNft
from the outgoing proxy!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
fn execute_receive_nft(
deps: DepsMut,
env: Env,
info: MessageInfo,
msg: Cw721ReceiveMsg,
) -> Result<Response, ContractError> {
// query whether there is an outgoing proxy defined by ics721
let outgoing_proxy_or_ics721 = match deps
.querier
.query_wasm_smart(ics721.clone(), &ics721::msg::QueryMsg::OutgoingProxy {})?
{
Some(outgoing_proxy) => outgoing_proxy,
None => ics721,
};
let mut ibc_msg: IbcOutgoingMsg = from_json(&msg.msg)?; // unwrap IbcOutgoingMsg binary
let memo = create_memo(deps.storage, env, msg.sender, msg.token_id.clone())?;
ibc_msg.memo = Some(Binary::to_base64(&to_json_binary(&memo)?)); // create callback and attach as memo
// forward nft to ics721 or outgoing proxy
let cw721 = info.sender;
let send_msg = WasmMsg::Execute { // send nft to proxy
contract_addr: cw721.to_string(),
msg: to_json_binary(&cw721_base::msg::ExecuteMsg::<
DefaultOptionalNftExtensionMsg,
DefaultOptionalCollectionExtensionMsg,
Empty,
>::SendNft {
contract: outgoing_proxy_or_ics721.to_string(),
token_id: msg.token_id,
msg: to_json_binary(&ibc_msg)?,
})?,
funds: vec![],
};
...
}
The Ics721Callbacks struct accepts these properties:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
pub struct Ics721Callbacks {
/// Data to pass with a callback on source side (status update)
/// Note - If this field is empty, no callback will be sent
pub ack_callback_data: Option<Binary>,
/// The address that will receive the callback message
/// Defaults to the sender address
pub ack_callback_addr: Option<String>,
/// Data to pass with a callback on the destination side (ReceiveNftIcs721)
/// Note - If this field is empty, no callback will be sent
pub receive_callback_data: Option<Binary>,
/// The address that will receive the callback message
/// Defaults to the receiver address
pub receive_callback_addr: Option<String>,
}
A contract can define its own custom callback data. In Arkite, it passes the sender and various token URIs as part of Ics721Memo:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
fn create_memo(
storage: &dyn Storage,
env: Env,
sender: String,
token_id: String,
) -> Result<Ics721Memo, ContractError> {
let default_token_uri = DEFAULT_TOKEN_URI.load(storage)?;
let escrowed_token_uri = ESCROWED_TOKEN_URI.load(storage)?;
let transferred_token_uri = TRANSFERRED_TOKEN_URI.load(storage)?;
let callback_data = CallbackData {
sender,
token_id,
default_token_uri,
escrowed_token_uri,
transferred_token_uri,
};
let mut callbacks = Ics721Callbacks {
ack_callback_data: Some(to_json_binary(&callback_data)?),
ack_callback_addr: Some(env.contract.address.to_string()),
receive_callback_data: None,
receive_callback_addr: None,
};
if let Some(counterparty_contract) = COUNTERPARTY_CONTRACT.may_load(storage)? {
callbacks.receive_callback_data = Some(to_json_binary(&callback_data)?);
callbacks.receive_callback_addr = Some(counterparty_contract); // here we need to set contract addr, since receiver is NFT receiver
}
Ok(Ics721Memo {
callbacks: Some(callbacks),
})
}
recv
Packet Processing on Target Chain and Execute Ics721ReceiveCallback
On the target chain, the ics721 contract gets a receive
packet:
- Entry point is ibc_packet_receive()
- Validates whether ics721 has been paused
- Creates various messages like:
- ics721 incoming proxy message triggers proxy and errors if the channel is not eligible to be passed to ics721 on the target chain.
- Depending on forward or back transfer, the NFT is either minted or unescrowed for the recipient
- Finally, the callback message is executed.
- Arkite contract processing execute_receive_callback()
- Calls cw721_base::msg::ExecuteMsg::UpdateNftInfo on the Passport collection
- Calls cw721_base::msg::ExecuteMsg::Mint on the POAP collection
- Finally, ics721 returns an ack success or error
- NOTE: In case any sub-message errors occur, ics721 reverts all changes.
Updating an NFT is straightforward. Here, on execute_receive_callback()
, the Arkite contract calls cw721_base::msg::ExecuteMsg::UpdateNftInfo
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
let new_token_uri = if current_token_uri == default_token_uri {
if use_escrowed_uri {
escrowed_token_uri.clone()
} else {
transferred_token_uri.clone()
}
} else {
default_token_uri.clone()
};
let trait_token_uri = Trait {
display_type: None,
trait_type: "token_uri".to_string(),
value: new_token_uri.clone(),
};
let trait_default_uri = Trait {
display_type: None,
trait_type: "default_uri".to_string(),
value: default_token_uri.clone(),
};
let trait_escrowed_uri = Trait {
display_type: None,
trait_type: "escrowed_uri".to_string(),
value: escrowed_token_uri.clone(),
};
let trait_transferred_uri = Trait {
display_type: None,
trait_type: "transferred_uri".to_string(),
value: transferred_token_uri.clone(),
};
let extension = Some(NftExtensionMsg {
image: Some(Some(new_token_uri.clone())),
attributes: Some(Some(vec![
trait_token_uri,
trait_default_uri,
trait_escrowed_uri,
trait_transferred_uri,
])),
..Default::default()
});
// - set new token uri
let update_nft_info: WasmMsg = WasmMsg::Execute {
contract_addr: cw721,
msg: to_json_binary(&cw721_base::msg::ExecuteMsg::<
DefaultOptionalNftExtensionMsg,
DefaultOptionalCollectionExtensionMsg,
Empty,
>::UpdateNftInfo {
token_id: callback_data.token_id.clone(),
token_uri: Some(Some(new_token_uri.clone())),
extension,
})?,
funds: vec![],
};
Additionally, the Arkite contract mints an NFT for the recipient as a reward:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
let extension = Some(NftExtensionMsg {
image: Some(Some(default_token_uri.clone())),
attributes: Some(Some(vec![
trait_token_uri,
trait_default_uri,
trait_escrowed_uri,
trait_transferred_uri,
])),
..Default::default()
});
let mint_msg = WasmMsg::Execute {
contract_addr: cw721.to_string(),
msg: to_json_binary(&cw721_base::msg::ExecuteMsg::<
DefaultOptionalNftExtensionMsg,
DefaultOptionalCollectionExtensionMsg,
Empty,
>::Mint {
token_id: num_tokens.count.to_string(),
owner,
token_uri: Some(default_token_uri.clone()),
extension,
})?,
funds: vec![],
};
Finally, ics721 sends an ack
packet on success. In case of failure, an error message is attached to the ack
packet.
ack
Packet Processing on Source Chain and Execute Ics721AckCallback
On the source chain, ics721 contracts get an ack
packet:
- Entry point is ibcpacketack(), handling
ack
success or fail: - handlepacketfail()
- Transfers NFT back to sender
- Executes callback with I
cs721Status::Failed
- On success, ics721
- Burns NFTs in case of back transfer
- Executes callback with
Ics721Status::Success
Important: Unlike the receive callback, ics721 ignores ack callback errors and won't rollback changes!
2. Arkite ack
callback
- Entry point is executeackcallback()
- Validates sender is ics721
- Ics721Status::Success: Updates NFT
- Ics721Status::Failed: Returns NFT back to sender
executeackcallback()
on Arkite contract deals straightforwardly with both ack fail and success:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
match msg.status {
Ics721Status::Success => {
let (update_nft_info, old_token_uri, new_token_uri) = create_update_nft_info_msg(
deps.as_ref(),
msg.nft_contract,
callback_data.clone(),
true,
)?;
Ok(res
.add_message(update_nft_info)
.add_attribute("old_token_uri", old_token_uri)
.add_attribute("new_token_uri", new_token_uri))
}
Ics721Status::Failed(error) => {
let transfer_msg = WasmMsg::Execute {
contract_addr: msg.nft_contract.to_string(),
msg: to_json_binary(&cw721_base::msg::ExecuteMsg::<
DefaultOptionalNftExtensionMsg,
DefaultOptionalCollectionExtensionMsg,
Empty,
>::TransferNft {
recipient: callback_data.sender,
token_id: callback_data.token_id,
})?,
funds: vec![],
};
Ok
(res.add_message(transfer_msg).add_attribute("error", error))
}
}
Future Prospects
The successful implementation of cw-ics721
and its extension for callbacks opens up numerous possibilities for NFT utilities across various blockchain ecosystems. With plans to extend support to Ethereum and other EVM chains, the potential for Interchain NFT interactions is limitless.
Conclusion
Ark Protocol’s cw-ics721
is paving the way for a new era of Interchain NFT utilities. By providing a secure, efficient, and scalable solution for InterChain NFT transfers, it empowers developers to explore innovative applications and expand the NFT ecosystem. Join us in this interstellar journey as we continue to push the boundaries of what's possible in the world of NFTs.
For a detailed implementation guide and access to the code, visit the cw-ics721-callback-example GitHub repository.