Open taitruong opened 5 months ago
Maybe interchaintest is the right fit here? Which could be either implemented forking the juno interchaintest implementation, which already comes with a bunch of helpful CosmWasm helpers or give it a try to the newily released interchaintest scaffolding tool from @srdtrk
Or a Rust alternative as implemented by Timewave as @Art3miX suggested that can be found here https://github.com/timewave-computer/orbital/tree/main/local-interchaintest
I had tested go-codegen with cw-ics721 and made sure it works thanks to @jhernandezb. I'll do my best to support your use case, and review some e2e PRs if you decide to go with my tool 😄 .
Yes! I've started some of this work locally using go-codegen, thanks @srdtrk
Here's a comprehensive code walkthrough for cw-ics721 newbies (will be published in IBC blog soon):
cw-ics721
: Explanation of its role in InterChain NFT transfers.cw-ics721
: Brief architecture intro on cw-ics721
.cw-ics721
.cw-ics721
including callbacks and NFT updates in action.cw-ics721
.The Cosmos 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 (IBC) protocol, Ark Protocol aims to create a unified NFT ecosystem where collections can be accessed and utilized on any chain, 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.
Mission:
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 team is one of the main contributor for cw-ics721
and cw-nfts
. Recent utilities we have provided are:
cw-nfts
cw721
package for better re-usecreator
and minter
CollectionInfo
in cw721
packageUpdateNftInfo
and UpdateCollectionInfo
msgMore InterChain utilities coming soon:
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
:
cw-ics721
managed by Ark Protocol uses a more advanced and secure outgoing proxy.ics721
across all Cosmos chains (and not only CosmWasm-based chains) from malicious or compromised chains.The process involved 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 on Osmosis: A minted NFT 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:
Note: PFP has changed from home
to away (=escrowed)
PFP!
Note: PFP has changed from 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: Note: PFP is reset to the 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
ics721
NFT transfers between Osmosis and Stargazeics721
transfer using callbackscw_ics721_arkite_passport.wasm
(aka Arkite
contract)
cw-ics721
transfer by passing a NFT to outgoing proxy contract and attaching callbacks in the memo
fieldcw_ics721_outgoing_proxy_rate_limit.wasm
cw-ics721
ics721_base.wasm
cw-ics721
, on the target chaincw_ics721_incoming_proxy_base.wasm
cw-ics721
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 $C HAIN_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.
Also check minted NFT using Ark's UI here: https://testnet.arkprotocol.io/collections/CW721_ADDRESS/NFT_ID
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:
debt voucher
), executing receive callback and sending an ack
packet back to target chainack
packet on source chain and executing ack callbackIn this example the receive
callback on target chain does 2 things:
The ack
callback on source chain in return:
Initializing an InterChain transfer covers these steps:
cw721_base::msg::ExecuteMsg::SendNft
on Passport collection contractSendNft
IbcOutgoingMsg
attachment in script can be found here.SendNft
ExecuteMsg::ReceiveNft
on Arkite contractIbcOutgoingMsg
data to ReceiveNft
IbcOutgoingMsg
attachment in contract can be found here.ReceiveNft
SendNFT
on Passport collection and attaching modified IbcOutgoingMsg
with callbacks as memoexecute_receive_nft()
main code is hereSendNft
ReceiveNft
on outgoing proxysend_nft()
code is here.ReceiveNft
ProxyExecuteMsg::ReceiveNft(cw721::Cw721ReceiveMsg)
on ics721 contractIbcOutgoingMsg
data to ReceiveNft
execute_receive_nft()
main code is hereReceiveNft
NonFungibleTokenPacketData
containing collection and NFT dataNonFungibleTokenPacketData
For simplicity please note, in step 1, that the recipient for Passport NFT is the Arkite contract on Stargaze, target chain! This way Arkite contract, being the creator of Passport collection, will be able to update NFT data.
In step 3, on execute_receive_nft() it does 5 things:
IbcOutgoingMsg
Ics721Memo
with receive and ack callbacksmemo
field into IbcOutgoingMsg
SendNft
with modified IbcOutgoingMsg
Note: in case an outgoing proxy address is set, then ics721 only accepts ReceiveNft
from 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 props:
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 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 target chain ics721 contracts gets a receive
packet:
Updating a NFT is straightforward, here on execute_receive_callback()
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![],
};
In addition Arkite mints an NFT for 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 a ack
packet on success. In case of failure an error message is attached to ack packet.
ack
Packet Processing on Source Chain and Execute Ics721AckCallback
On source chain ics721 contracts gets an ack
packet:
Ics721Status::Failed
Ics721Status::Success
execute_ack_callback()
on Arkite contract is straightforward dealing 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.
This technical deep dive aims to inspire developers across the Cosmos ecosystem to explore and implement cw-ics721
, enhancing the functionality and reach of NFTs.
As of now ics721 covers e2e tests using
ts-relayer
. This is for various reasons not ideal, sincets-relayer
is neither meant for production nor it is up to date.New e2e tests should cover tests as defined in ./ts-relayer-tests/src/ics721.spec.ts.