public-awesome / cw-ics721

CosmWasm IBC NFT Transfers
MIT License
62 stars 31 forks source link

replacing outdated `ts-relayer` with new e2e tests #99

Open taitruong opened 5 months ago

taitruong commented 5 months ago

As of now ics721 covers e2e tests using ts-relayer. This is for various reasons not ideal, since ts-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.

magiodev commented 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

srdtrk commented 5 months ago

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 😄 .

jhernandezb commented 5 months ago

Yes! I've started some of this work locally using go-codegen, thanks @srdtrk

taitruong commented 5 months ago

Here's a comprehensive code walkthrough for cw-ics721 newbies (will be published in IBC blog soon):

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

Agenda

  1. Introduction to cw-ics721: Explanation of its role in InterChain NFT transfers.
  2. About Ark Protocol: Building an InterChain NFT Hub across all CosmWasm-based Cosmos chains.
  3. Understanding cw-ics721: Brief architecture intro on cw-ics721.
  4. Understanding Proxies for Security Considerations: The importance of proxies for securing cw-ics721.
  5. Case Study: Full example of cw-ics721 including callbacks and NFT updates in action.
  6. Implementation Walkthrough: Step-by-step guide on setting up and using cw-ics721.
  7. Future Prospects: Expansion plans and potential applications.
  8. Conclusion: Empowering developers for a new era of InterChain NFT utilities.

Introduction

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.

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

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 Proxies for Security Considerations

Ark provides additional security measures to prevent possible exploits and malicious attacks on ics721:

Case Study

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:

  1. Minting an NFT on Osmosis: A minted NFT on Osmosis (source/home chain) looks like this: minted nft

  2. Transferring NFT to Stargaze: After transferring the NFT to Stargaze, it is escrowed on Osmosis, and its metadata is updated: transfer, home chain Note: PFP has changed from home to away (=escrowed) PFP!

    transfer, sub chain Note: 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: backtransfer Note: 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:

Arkite Messages

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),
}

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:

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

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:

The ack callback on source chain in return:

Initialize InterChain Transfer on Source Chain with Callbacks

Initializing an InterChain transfer covers these steps:

  1. Script triggers InterChain transfer
    • executes cw721_base::msg::ExecuteMsg::SendNft on Passport collection contract
    • attaches IbcOutgoingMsg data to SendNft
    • IbcOutgoingMsg attachment in 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 contract can be found here.
  3. Arkite contract processing ReceiveNft
    • forwards/retransfers NFT to outgoing proxy contract by calling SendNFT on 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 calls ReceiveNft on 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 ics721 contract
    • calls ProxyExecuteMsg::ReceiveNft(cw721::Cw721ReceiveMsg) on ics721 contract
    • attaches IbcOutgoingMsg data to ReceiveNft
    • execute_receive_nft() main code is here
  6. ICS721 contract processing ReceiveNft
    • validates whether sender is outgoing proxy
    • validates 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, 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:

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:

  1. entry point is ibc_packet_receive()
  2. validates whether ics721 has been paused
  3. creates varioius msgs like:
  4. Arkite contract processing execute_receive_callback()
  5. finally ics721 returns an ack success or error
    • NOTE: in case any sub message errors, ics721 reverts all changes

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:

  1. entry point is ibc_packet_ack(), handling ack success or fail:
    • handle_packet_fail()
      • transfers NFT back to sender
      • executes callback with Ics721Status::Failed
    • on success ics721
      • burn NFTs in case of back transfer
      • executes callback with Ics721Status::Success
    • Important: unlike receive callback, ics721 ignores ack callback errors and wont rollback changes!
  2. Arkite ack callback

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))
   }
}

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.


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.