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.
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.
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:
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.)
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.
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.
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:
Minting an NFT PFP on Osmosis:
A minted NFT PFP on Osmosis (source/home chain) looks like this:
Transferring NFT to Stargaze:
After transferring the NFT to Stargaze, it is escrowed on Osmosis, and its metadata is updated.
home
to away (=escrowed)
PFP!home
to transferred (=debt voucher)
PFP!Transferring Back to Osmosis:
When transferring an NFT back to the home chain, it is burned on Stargaze and reset on Osmosis.
home
PFP!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.
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.
The Arkite contract provides the following messages:
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),
}
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:
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 $ADDR
CW721 '{"nft
info":{"token
id": "1"}}' --chain-id $CHAIN
ID --node $CHAIN_NODE | jq
provides the following NFT details:
{
"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
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 an ack
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.
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 to ReceiveNft
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 modified IbcOutgoingMsg
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 to ReceiveNft
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 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!
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:
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:
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
.
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:
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:
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!
Arkite ack
callback
Entry point is executeackcallback()
Ics721Status::Success: Updates NFT
Ics721Status::Failed: Returns NFT back to sender
executeackcallback()
on Arkite contract deals straightforwardly with both ack fail and success:
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))
}
}
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.
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.