Deploy a cross-chain application using GMP
Overview
This guide explains how to build the logic for a cross-chain app using Axelar GMP (General Message Passing).
You'll deploy two contracts:
- An EVM contract on Ethereum Sepolia
- A WASM contract on Warden (Buenavista testnet)
After you execute the WASM contract on Warden, it'll be able to burn tokens from the EVM contract on Sepolia. For cross-chain interaction, the contracts will use the x/gmp module, which enables Axelar GMP.
Note that this guide assumes you have a basic familiarity with Solidity and Rust and smart contract deployment.
Axelar GMP will be soon available on our new testnet – Chiado. Stay tuned in for updates!
1. Deploy an EVM contract on Sepolia
The contract overview
In this section, you'll deploy an EVM contract on Ethereum Sepolia.
Here are the key features of this contract:
- It'll function both as an ERC20 token and a cross-chain executable contract.
- It'll be able to receive burn instructions from other chains through the Axelar network.
- The initial token supply will be minted to the contract itself, not to any external address.
- Burning can only be done from the balance of the contract, not from user balances.
Prerequisites
Before you start, complete the following prerequisites:
- Install
node.js
andnpm
. - Install
truffle
globally:npm install -g truffle
. - Create a wallet and get Sepolia ETH – for example, from the PoW Sepolia Faucet.
- Create an Infura account for accessing the Sepolia network.
1.1. Set up the project
-
Create a new directory
/burnable-token
and initialize a Truffle project:mkdir burnable-token
cd burnable-token
truffle init -
Install necessary dependencies:
npm init -y
npm install @axelar-network/axelar-gmp-sdk-solidity
npm install @openzeppelin/contracts
npm install @truffle/hdwallet-provider
npm install dotenv -
In the root directory, create a file named
.env
to store your private key and the Infura project ID:/burnable-token/.envPRIVATE_KEY=my-private-key
INFURA_PROJECT_ID=my-infura-project-id
1.2. Add the contract
- In the
/contracts
directory, create a new fileBurnableToken.sol
with the following contents:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@axelar-network/axelar-gmp-sdk-solidity/contracts/executable/AxelarExecutable.sol";
import "@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IAxelarGateway.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
/**
* @title BurnableToken
* @dev An ERC20 token that can be burned through cross-chain messages using Axelar
*/
contract BurnableToken is AxelarExecutable, ERC20Burnable {
// An event emitted when tokens are burned
event TokensBurned(uint256 amount);
/**
* @dev A constructor that mints an initial supply of tokens to the contract
* @param gateway_ The address of the Axelar gateway contract
* @param initialSupply The initial supply of tokens to mint
*/
constructor(address gateway_, uint256 initialSupply)
AxelarExecutable(gateway_)
ERC20("WardenTokens", "AM")
{
// Mint the initial supply to the contract address
_mint(address(this), initialSupply);
}
/**
* @dev Handles cross-chain messages received through Axelar
* @param sourceChain The name of the source chain
* @param sourceAddress The address of the source contract on the source chain
* @param payload The payload sent from the source chain (the amount to burn)
*/
function _execute(
string calldata sourceChain,
string calldata sourceAddress,
bytes calldata payload
) internal override {
// Decode the payload to get the amount to burn
uint256 amountToBurn = abi.decode(payload, (uint256));
// Burn the specified amount of tokens
burnTokens(amountToBurn);
}
/**
* @dev Burns a specified amount of tokens from the contract's balance
* @param amount The amount of tokens to burn
*/
function burnTokens(uint256 amount) public {
require(balanceOf(address(this)) >= amount, "Insufficient balance to burn");
_burn(address(this), amount);
emit TokensBurned(amount);
}
// Allows the contract to receive native currency – for example, ETH
receive() external payable {}
}
1.3. Configure Truffle
In the root directory, update the truffle-config.js
file to include the Sepolia network:
require('dotenv').config();
const HDWalletProvider = require('@truffle/hdwallet-provider');
module.exports = {
networks: {
sepolia: {
provider: () => new HDWalletProvider(
process.env.PRIVATE_KEY,
`https://sepolia.infura.io/v3/${process.env.INFURA_PROJECT_ID}`
),
network_id: 11155111,
gas: 5500000,
confirmations: 2,
timeoutBlocks: 200,
skipDryRun: true
},
},
compilers: {
solc: {
version: "0.8.20",
}
},
};
1.4. Create a migration script
Now you need to add a migration script that will deploy the contract and mint the initial supply to the contract address.
In /migrations
, create a new file migrations/2_deploy_contracts.js
with the following contents:
const BurnableToken = artifacts.require("BurnableToken");
module.exports = async function (deployer, network, accounts) {
const AXELAR_GATEWAY_ADDRESS = "0xe432150cce91c13a887f7D836923d5597adD8E31";
// Define the initial supply – for example, 100 tokens with 18 decimals
const initialSupply = web3.utils.toWei("100000000", "ether"); // Mints 100M tokens
/burnable-token
// Deploy the BurnableToken contract with the required constructor parameters
await deployer.deploy(
BurnableToken,
AXELAR_GATEWAY_ADDRESS,
initialSupply
);
};
To verify the Ethereum Sepolia gateway address in AXELAR_GATEWAY_ADDRESS
or find gateway contracts for other chains, see EVM contract addresses in the Axelar documentation.
1.5. Compile the contract
Run the following command to compile your contract:
truffle compile
1.6. Deploy the contract
-
To deploy the contract, make sure your wallet has enough Sepolia ETH. Then run the migration script:
truffle migrate --network sepolia
You'll see an output similar to the following:
Starting migrations...
======================
> Network name: 'sepolia'
> Network id: 11155111
> Block gas limit: 30000000 (0x1c9c380)
2_deploy_contracts.js
=====================
Deploying 'BurnableToken'
-------------------------
> transaction hash: 0x969021618f339d2e5231920652699b13071adb44fcce27cf7d46dca9e2dcba61
> Blocks: 0 Seconds: 4
> contract address: 0x5388dE880a16Ba9602746F3799E850E78148cd57
> block number: 6688280
> block timestamp: 1726294248
> account: 0xc00d0c1255883B9c0D8D3a17927F5b8a06802937
> balance: 0.371481630374370861
> gas used: 950251 (0xe7feb)
> gas price: 3.567435322 gwei
> value sent: 0 ETH
> total cost: 0.003389958982165822 ETH
Pausing for 2 confirmations...
-------------------------------
> confirmation number: 1 (block: 6688281)
> confirmation number: 2 (block: 6688282)
> Saving artifacts
-------------------------------------
> Total cost: 0.003389958982165822 ETH
Summary
=======
> Total deployments: 1
> Final cost: 0.003389958982165822 ETH -
Note down the contract address returned in the
contract address
field of the deployment output.
1.7. Verify the deployment
Congratulations! You've successfully deployed your BurnableToken
contract to the Sepolia testnet. Remember to keep your private keys and sensitive information secure and never share your .env
file.
Verify the deployment by checking the contract address on Etherscan. For example, you can find the contract from this guide here: contract 0x5388...cd57.
2. Deploy a WASM contract on Warden
The contract overview
In the following steps, you'll deploy a WASM contract on a Warden testnet, Buenavista. This contract will interact with the BurnableToken
contract from the first part of the guide.
Here are the key points of this contract:
- It'll function as a bridge between a contract deployed on Warden and a contract deployed on an EVM chain (Sepolia in this case).
- It'll use Axelar's infrastructure for cross-chain communication.
- The burning amount will be passed from the WASM contract to the EVM contract.
- Gas fees for cross-chain execution will be included in the transaction.
Prerequisites
Before you start, do the following:
- If you wish to learn the basics, follow this guide: Deploy a WASM contract.
- Install Rust and set up the CosmWasm environment, as shown in the guide.
- For interacting with the node, install Go 1.22.3 or later and just 1.34.0 or later.
- Obtain some AXL tokens in the Axelar network.
2.1. Create a WASM contract
Start by creating a WASM contract that will burn tokens on the EVM contract:
-
Create a CosmWasm project. You can use a template.
-
In the
/src
directory of your project, create acontract.rs
file with the code below. If you've used a template, update the existing file.Set
destination_address
to the EVM contract address from Step 1.6. Optionally, modify the code to let users input the address during execution./my-wasm-project/src/contract.rs#[cfg(not(feature = "library"))]
// Import standard CosmWasm libraries
use cosmwasm_std::{Uint256, DepsMut, Env, MessageInfo, Response};
// Import a library for Ethereum ABI encoding
use ethabi::{encode, Token};
// Import custom modules
use serde_json_wasm::to_string;
use crate::error::ContractError;
use crate::msg::*;
// This function is called when the contract is first deployed
// It currently doesn't perform any initialization, just returns an empty response
pub fn instantiate(
_deps: DepsMut,
_env: Env,
_info: MessageInfo,
_msg: InstantiateMsg,
) -> Result<Response, ContractError> {
Ok(Response::new())
}
// This function is the main entry point for contract execution
// It matches on the `ExecuteMsg` enum, currently only handling `SendMessageEvm`
pub fn execute(
deps: DepsMut,
env: Env,
info: MessageInfo,
msg: ExecuteMsg,
) -> Result<Response, ContractError> {
match msg {
ExecuteMsg::SendMessageEvm { amount_to_burn } => {
exec::send_message_evm(deps, env, info, amount_to_burn)
}
}
}
mod exec {
use super::*;
use ethabi::ethereum_types::U256;
// This function creates an Ethereum ABI payload for burning tokens
fn create_burn_payload(amount: Uint256) -> Result<Vec<u8>, ContractError> {
// Convert the Uint256 amount to burn to a big-endian byte array
let amount_bytes = amount.to_be_bytes();
// Convert the big-endian byte array to a U256
let amount_u256 = U256::from_big_endian(&amount_bytes);
// Wrap the amount in a token
let amount_token = Token::Uint(amount_u256);
// Encode the amount as an Ethereum ABI payload
Ok(encode(&[amount_token]))
}
// This function burns tokens on an EVM chain
// It sends a message through Axelar GMP to a given chain and address
pub fn send_message_evm(
_deps: DepsMut,
env: Env,
info: MessageInfo,
amount_to_burn: Uint256, // The amount to burn
) -> Result<Response, ContractError> {
// Hardcode the destination chain and address
let destination_chain = "ethereum-sepolia".to_string();
let destination_address = "0x5388dE880a16Ba9602746F3799E850E78148cd57".to_string();
// Create a burn payload
let payload = create_burn_payload(amount_to_burn)?;
// Extract the coin sent with the transaction for gas fees
// It must contain only 1 token type
let coin: cosmwasm_std::Coin = cw_utils::one_coin(&info).unwrap();
// Construct a GMP message with the hardcoded destination values
// Include the payload and fee information
let gmp_message: GmpMessage = GmpMessage {
destination_chain,
destination_address,
payload,
type_: 1,
fee: Some(Fee {
amount: coin.amount.to_string(),
recipient: "axelar1zl3rxpp70lmte2xr6c4lgske2fyuj3hupcsvcd".to_string(),
}),
};
// Construct an IBC transfer message
// It'll send the GMP message to the Axelar network
let ibc_message = crate::ibc::MsgTransfer {
source_port: "transfer".to_string(),
source_channel: "channel-1".to_string(), // The Warden testnet
token: Some(coin.into()),
sender: env.contract.address.to_string(),
receiver: "axelar1dv4u5k73pzqrxlzujxg3qp8kvc3pje7jtdvu72npnt5zhq05ejcsn5qme5"
.to_string(),
timeout_height: None,
timeout_timestamp: Some(env.block.time.plus_seconds(604_800u64).nanos()),
memo: to_string(&gmp_message).unwrap(),
};
Ok(Response::new().add_message(ibc_message))
}
}
2.2. Add supporting code
In the following steps, you'll create files in the /src
folder to add supporting code for your contract. If you're using a CosmWasm project template, just update the existing files.
-
Create a file named
msg.rs
with the following code:/my-wasm-project/src/msg.rsuse cosmwasm_schema::cw_serde;
use cosmwasm_std::Uint256;
#[cw_serde]
pub struct InstantiateMsg {}
#[cw_serde]
pub enum ExecuteMsg {
SendMessageEvm {
amount_to_burn: Uint256,
},
}
#[cw_serde]
pub enum QueryMsg {
GetStoredMessage {},
}
#[cw_serde]
pub struct GetStoredMessageResp {
pub sender: String,
pub message: String,
}
#[cw_serde]
pub struct Fee {
pub amount: String,
pub recipient: String,
}
#[cw_serde]
pub struct GmpMessage {
pub destination_chain: String,
pub destination_address: String,
pub payload: Vec<u8>,
#[serde(rename = "type")]
pub type_: i64,
pub fee: Option<Fee>,
} -
Create a file named
error.rs
with the following code:/my-wasm-project/src/error.rsuse cosmwasm_std::StdError;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ContractError {
#[error("{0}")]
Std(#[from] StdError),
#[error("Unauthorized")]
Unauthorized {},
#[error("Serialization error")]
SerializationError,
} -
Create a file named
helpers.rs
file with the following code:/my-wasm-project/src/helpers.rsuse schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use cosmwasm_std::{to_json_binary, Addr, CosmosMsg, StdResult, WasmMsg};
use crate::msg::ExecuteMsg;
// CwTemplateContract is a wrapper around Addr
// It provides various helper functions for working with the contract
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
pub struct CwTemplateContract(pub Addr);
impl CwTemplateContract {
pub fn addr(&self) -> Addr {
self.0.clone()
}
pub fn call<T: Into<ExecuteMsg>>(&self, msg: T) -> StdResult<CosmosMsg> {
let msg = to_json_binary(&msg.into())?;
Ok(WasmMsg::Execute {
contract_addr: self.addr().into(),
msg,
funds: vec![],
}
.into())
}
} -
Create a file named
ibc.rs
with the following code:/my-wasm-project/src/ibc.rsuse osmosis_std_derive::CosmwasmExt;
#[derive(
Clone,
PartialEq,
Eq,
::prost::Message,
serde::Serialize,
serde::Deserialize,
schemars::JsonSchema,
)]
pub struct IbcCounterpartyHeight {
#[prost(uint64, optional, tag = "1")]
revision_number: Option<u64>,
#[prost(uint64, optional, tag = "2")]
revision_height: Option<u64>,
}
// Define the transfer as a Stargate message
// It's required because IBC token transfers aren't fully supported by cosmwasm-std
#[derive(
Clone,
PartialEq,
Eq,
::prost::Message,
serde::Serialize,
serde::Deserialize,
schemars::JsonSchema,
CosmwasmExt,
)]
#[proto_message(type_url = "/ibc.applications.transfer.v1.MsgTransfer")]
pub struct MsgTransfer {
#[prost(string, tag = "1")]
pub source_port: String,
#[prost(string, tag = "2")]
pub source_channel: String,
#[prost(message, optional, tag = "3")]
pub token: ::core::option::Option<osmosis_std::types::cosmos::base::v1beta1::Coin>,
#[prost(string, tag = "4")]
pub sender: String,
#[prost(string, tag = "5")]
pub receiver: String,
#[prost(message, optional, tag = "6")]
pub timeout_height: Option<IbcCounterpartyHeight>,
#[prost(uint64, optional, tag = "7")]
pub timeout_timestamp: ::core::option::Option<u64>,
#[prost(string, tag = "8")]
pub memo: String,
}
// Define the response as a prost message to facilitate decoding of the protobuf data
#[derive(Clone, PartialEq, Eq, ::prost::Message)]
pub struct MsgTransferResponse {
#[prost(uint64, tag = "1")]
pub sequence: u64,
} -
Create a file named
lib.rs
with the following code:/my-wasm-project/src/lib.rspub mod contract;
mod error;
mod ibc;
pub mod msg;
pub mod state;
#[cfg(test)]
mod unit_tests;
pub use crate::error::ContractError;
use msg::{ExecuteMsg, InstantiateMsg};
use cosmwasm_std::entry_point;
use cosmwasm_std::{DepsMut, Env, MessageInfo, Response};
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn instantiate(
deps: DepsMut,
env: Env,
info: MessageInfo,
msg: InstantiateMsg,
) -> Result<Response, ContractError> {
contract::instantiate(deps, env, info, msg)
}
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn execute(
deps: DepsMut,
env: Env,
info: MessageInfo,
msg: ExecuteMsg,
) -> Result<Response, ContractError> {
contract::execute(deps, env, info, msg)
} -
Create a file named
state.rs
with the following code:/my-wasm-project/src/state.rsuse cosmwasm_schema::cw_serde;
use cw_storage_plus::Item;
#[cw_serde]
pub struct Message {
pub sender: String,
pub message: String,
}
pub const STORED_MESSAGE: Item<Message> = Item::new("storedmessage");
2.3. Compile & optimize
Now you can compile and optimize your contract.
2.4. Create a Warden account
-
Download Warden v.0.4.1 and navigate to the root directory:
git clone --depth 1 --branch v0.4.1 https://github.com/warden-protocol/wardenprotocol
cd wardenprotocol -
Build the
wardend
binary and install it in your$GOPATH
:just build
just install -
Verify the installation:
wardend version
You should see the following output:
v0.4.1
-
Create a key, specifying a custom key (account) name::
wardend keys add my-key-name
You'll be prompted to create a passphrase, which is required for confirming some of the next steps.
warningAfter you enter the passphrase, the node will return the account address and a mnemonic phrase. Note them down: you'll need this data for recovering your account if necessary.
-
Get some WARD in Buenavista faucet: paste the address returned in the previous step.
You can verify that your account is funded by running the command below. Specify the custom key name you chose before.
wardend query bank balances my-key-name --node https://rpc.buenavista.wardenprotocol.org:443
2.5. Deploy on Buenavista
-
Now you can store your contract on Buenavista by running the following command. Replace
my-key-name
with your key name from the previous step.wardend tx wasm store target/wasm32-unknown-unknown/release/burn_tokens.wasm \
--from my-key-name \
--gas auto \
--gas-adjustment 1.5 \
--gas-prices 0.025uward -y \
--chain-id buenavista-1 \
--node https://rpc.buenavista.wardenprotocol.org:443tipBuenavista uses the
cosmos.crypto.secp256k1
module for cryptographic operations. If your key is created with theethermint.crypto.v1.ethsecp256k1
module, downgrade your node tov0.4.1
and create a key withcosmos.crypto.secp256k1
. -
Get the code ID that identifies your WASM contract:
wardend query wasm list-code --node https://rpc.buenavista.wardenprotocol.org:443
Note down
code_id
from the output. -
Instantiate the contract by using the command below.
Before you proceed, replace
1
with the actual code ID andmy-key-name
with your key name. Also note that you can either define an admin or pass--no-admin
to make it immutable, like in this example.wardend tx wasm instantiate 1 '{}' \
--from my-key-name \
--label "Burn Tokens" \
--gas auto \
--gas-adjustment 1.5 \
--gas-prices 0.025uward \
--no-admin -y \
--chain-id buenavista-1 \
--node https://rpc.buenavista.wardenprotocol.org:443 -
To get the contract address, run the following command. Replace
1
with the actualcode_id
.wardend query wasm list-contract-by-code 1 --node https://rpc.buenavista.wardenprotocol.org:443
-
Use the command below to execute your contract.
Before you proceed, replace
my-contract-address
with your contract address andmy-key-name
with your key name. The--amount
flag specifies the gas fee in the Axelar network – make sure you have enough AXL.wardend tx wasm execute my-contract-address '{"send_message_evm": {"amount_to_burn": "1000000"}}' \
--from my-key-name \
--amount 3000000ibc/0E1517E2771CA7C03F2ED3F9BAECCAEADF0BFD79B89679E834933BC0F179AD98 \
--gas auto \
--gas-adjustment 1.5 \
--gas-prices 0.025uward \
-y \
--chain-id buenavista-1 \
--node https://rpc.buenavista.wardenprotocol.org:443
2.6. Verify the deployment
Congratulations! You've successfully deployed your WASM contract on Warden.
Now you can verify the deployment and see how the EVM and WASM contracts interact:
-
Verify the deployment by checking the transaction hash on the Buenavista chain explorer. For example, the following verifies that the contract from this guide was executed: transaction 924E...6428.
-
To verify the transfer of tokens through GMP, visit Axelarscan.
-
To verify the token burn, visit Etherscan.
If you encounter any issues, please reach out to us in Discord or Twitter.
Happy coding! 🚀