logo

Unleashing Interchain NFT Utilities with cw-ics721 and Callbacks: A Comprehensive Implementation Guide

Tai, aka "Mr T", Truong
June 13, 2024
15 Min
article cover image

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 and minter
      • NEW CollectionInfo in cw721 package
      • NEW utility: UpdateNftInfo and UpdateCollectionInfo 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.

collection-contract-light.png
NFT transfer flow from a CosmWasm NFT transfer contract.

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:

passport_osmosis01_home.png
An NFT on Osmosis.

2. Transferring NFT to Stargaze:

After transferring the NFT to Stargaze, it is escrowed on Osmosis, and its metadata is updated.

passport_osmosis02_away.png
PFP has changed from home to away (=escrowed) PFP!
passport_osmosis03_transferred.png
PFP has changed from home to transferred (=debt voucher) PFP!

3. Transferring Back to Osmosis:

When transferring an NFT back to the home chain, it is burned on Stargaze and reset on Osmosis.

passport_osmosis01_home_reset.png
PFP is reset to the home PFP!

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 (aka Arkite 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:

  1. Initialize Interchain transfer on Osmosis as source chain, and attaching receive and ack callbacks
  2. Receive NFT packet on Stargaze, target chain, for minting a Passport NFT (aka debt voucher), executing receive callback and sending an ack packet back to target chain.
  3. 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:

  1. 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.
  2. Passport collection contract processing SendNft
    • Transfers NFT to Arkite contract
    • Contract calls ExecuteMsg::ReceiveNft on Arkite contract
    • Attaches IbcOutgoingMsg data to ReceiveNft
    • IbcOutgoingMsg attachment in the contract can be found here.
  3. Arkite contract processing ReceiveNft
    • Forwards/retransfers NFT to the outgoing proxy contract by calling SendNFT on the Passport collection and attaching modified IbcOutgoingMsg with callbacks as memo
    • execute_receive_nft() main code is here
  4. Passport collection contract processing SendNft
    • Same as above: transfer NFT ownership and call ReceiveNft on the outgoing proxy
    • send_nft() code is here.
  5. 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 to ReceiveNft
    • execute_receive_nft() main code is here
  6. 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 into IbcOutgoingMsg
  • Forwards and executes SendNft with modified IbcOutgoingMsg

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:

  1. Entry point is ibc_packet_receive()
  2. Validates whether ics721 has been paused
  3. Creates various messages like:
  4. Arkite contract processing execute_receive_callback()
  5. 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:

  1. Entry point is ibcpacketack(), handling ack success or fail:
    • handlepacketfail()
      • Transfers NFT back to sender
      • Executes callback with Ics721Status::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

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.

Tai, aka "Mr T", Truong
Founder of Ark Protocol

Recent Articles

Cover for Getting Started With IBC: Understanding the Interchain Stack and the Main IBC ImplementationsGetting Started With IBC: Understanding the Interchain Stack and the Main IBC Implementations
This article aims to provide an overview of IBC and its main implementations, ibc-go and ibc-rs, as well as other components of the Interchain stack, namely CometBFT and the Cosmos SDK.
avatar
Adi Ravi Raj
October 16, 2023
5 Min
Cover for Winners of the IBC Ideathon, Ideahacker's Guide to IBCWinners of the IBC Ideathon, Ideahacker's Guide to IBC
Announcing the winners of the first-ever IBC Ideathon, Ideahacker's Guide to IBC!
avatar
IBC Protocol
November 15, 2024
4 Min
Head to our Github to begin.

Ready to get started?