Stellar Soroban GMP Example

Axelar General Message Passing (GMP) allows for messages to be sent between two chains. Such messages can be used to power natively cross-chain decentralized applications. With the integration of Stellar to Axelar, Soroban based contracts can now send messages to other blockchain ecosystems connected to Axelar, including (but not limited to) EVM chains.

To begin, make sure the Stellar CLI is setup on your local machine.

Once the Stellar CLI is setup you can run stellar contract init axelar-gmp. This will create a new Stellar Soroban project.

Now that you have a Stellar project you can run cargo install axelar-cgp-soroban to install the Axelar related functionality to be used in your contract.

For simplicity, you can remove the root files and unnest the files in the src folder to be at the root of your project so that your file structures now look like this.

Terminal window
Cargo.lock
Cargo.toml
Makefile
src

The files in the src folder can now be broken out into a contract.rs file, an event.rs, and lib.rs.

To integrate with the Axelar Network you will need to leverage the contracts in the Axelar CGP Soroban repository. Currently, this repo is not available on crates.io. To access it’s functionality you can reference it as a git dependency. You can add a dependency for each subcrate in the CGP Soroban repo as follows:

[dependencies]
axelar-gateway = { git = "https://github.com/axelarnetwork/axelar-cgp-soroban", subdir = "contracts/axelar-gateway", features = [
'library',
] }
axelar-gas-service = { git = "https://github.com/axelarnetwork/axelar-cgp-soroban", subdir = "contracts/axelar-gas-service", features = [
'library',
] }
axelar-soroban-std = { git = "https://github.com/axelarnetwork/axelar-cgp-soroban", subdir = "packages/axelar-soroban-std" }

The lib.rs file which contains the hello_world code can be moved to the contract.rs file. You can replace all that code with a module representation of the contract. Your lib.rs file should look like this:

#![no_std]
pub mod contract;

You can rename your contract from the default name Contract to AxelarGMP. You can then begin writing out the constructor. Before writing functionality of the contract you can also make several key data types available from the Soroban SDK

use soroban_sdk::{contract, contractimpl, Address, Env, String, Vec};

These unique types will be used throughout the contract.

In the constructor function you will need to pass in the Axelar Gateway and Gas Service contracts.

pub fn __constructor(env: Env, gateway: Address, gas_service: Address) {}

Note: In addition to the gateway and gas service you will also set an env param. This is an instance of the Env struct, which is a core part of the Soroban SDK. The Env struct provides access to various environment-specific functions and capabilities necessary for interacting with the contract’s storage, managing resources, and executing contract logic within the Soroban runtime.

The gateway and gas service contracts can be stored in the contract’s instance storage. Before you can save the two parameters in storage, you must first define the storage variables. Create a new file called storage_types.rs

This can be done as follows:

use soroban_sdk::contracttype;
#[contracttype]
#[derive(Clone, Debug)]
pub enum DataKey {
Gateway,
GasService,
}

The storage_types.rs file defines the DataKey enum, used as a typed storage key within the contract.

#[contracttype]: Marks the enum for serialization and storage by the Soroban SDK. Enum Variants: Gateway: Represents the storage key for the gateway contract. GasService: Represents the storage key for the gas service contract.

For this enum to be reachable you must:

  1. Define the storage_types.rs file in the lib.rs file, which at this point should look like this.
#![no_std]
pub mod contract;
mod storage_types;
  1. Make the file available to your contracts.rs repo
use crate::storage_types::DataKey;

With the storage now setup you can store your gateway and gas serivce contracts in storage from the constructor. The complete constructor should now look like this.

pub fn __constructor(env: Env, gateway: Address, gas_service: Address) {
env.storage().instance().set(&DataKey::Gateway, &gateway);
env.storage()
.instance()
.set(&DataKey::GasService, &gas_service);
}

The constructor stores each param at a specific key in the DataKey enum you created before.

The Gas Service contract implements the functionality to pay for cross-chain transactions.

pub fn gas_service(env: &Env) -> Address {
env.storage().instance().get(&DataKey::GasService).unwrap()
}

The Gateway contract implements the functionality to send cross-chain transactions.

fn gateway(env: &Env) -> Address {
env.storage().instance().get(&DataKey::Gateway).unwrap()
}

To send a cross-chain message you can define a new function called send(). It will take the following function signature

pub fn send(
env: Env,
caller: Address,
destination_chain: String,
destination_address: String,
message: Bytes,
gas_token: Token,
) {}

For the token type to be accepted you must make it available as follows:

use axelar_soroban_std::types::Token;

You will also need the GasService and Gateway client types available for other functionality in this function.

The entire list of imported data types should now be

use axelar_gas_service::AxelarGasServiceClient;
use axelar_gateway::AxelarGatewayMessagingClient;
use crate::storage_types::DataKey;
use axelar_soroban_std::types::Token;
use soroban_sdk::{contract, contractimpl, Address, Bytes, Env, String}; // Import the gateway

The function takes the following parameters

  1. env: The environment object, providing access to the blockchain runtime.
  2. caller: The address of the account initiating the message send.
  3. destination_chain: The name of the destination blockchain.
  4. destination_address: The address on the destination blockchain the call is being sent to.
  5. message: The message being sent to the destination chain.
  6. gas_token: The token used to pay for gas fees.

Now you can access an instance for the Gateway and GasService contracts set in storage in the constructor.

let gateway = AxelarGatewayMessagingClient::new(&env, &Self::gateway(&env));
let gas_service = AxelarGasServiceClient::new(&env, &Self::gas_service(&env));

Before, triggering the cross-chain transaction you must verify the authenticity of the caller’s signature by calling require_auth

caller.require_auth();

You can now move on to triggering the cross-chain message. This involves two steps.

  1. Pay the GasService
  2. Trigger the cross-chain call on the Gateway

The process of paying the GasService involves running the pay_gas() function.

gas_service.pay_gas(
&env.current_contract_address(),
&destination_chain,
&destination_address,
&message,
&caller,
&gas_token,
&Bytes::new(&env),
);

Once triggered, the gas that is paid to the GasService will be used to cover the cost of the relaying the transaction between the two chains, verifying the transaction on the Axelar Network, and gas costs of executing the transaction on the destination chain.

The function takes seven arguments passed in as references:

  1. &env.current_contract_address(): The address of the sender
  2. &destination_chain: The name of the destination chain
  3. &destination_address: The receiving address of the cross-chain call on the destination chain
  4. &message: The GMP message being sent to the destination chain
  5. &caller: The address paying the gas cost
  6. &gas_token: The token being used to cover the gas cost

With the transaction now paid for, the final step for sending a cross-chain message is to trigger the callContract() function on the Gateway.

gateway.call_contract(
&env.current_contract_address(),
&destination_chain,
&destination_address,
&message,
);

For this function you will pass in

  1. &env.current_contract_address(): The address of the
  2. &destination_chain: The name of the destination chain
  3. &destination_address: The receiving address of the cross-chain call on the destination chain
  4. &message: The GMP message being sent to the destination chain

Your completed send() function now should be as follows:

pub fn send(
env: Env,
caller: Address,
destination_chain: String,
destination_address: String,
message: Bytes,
gas_token: Token,
) {
let gateway = AxelarGatewayMessagingClient::new(&env, &Self::gateway(&env));
let gas_service = AxelarGasServiceClient::new(&env, &Self::gas_service(&env));
caller.require_auth();
gas_service.pay_gas(
&env.current_contract_address(),
&destination_chain,
&destination_address,
&message,
&caller,
&gas_token,
&Bytes::new(&env),
);
gateway.call_contract(
&env.current_contract_address(),
&destination_chain,
&destination_address,
&message,
);
}

Now that you are able to send a cross-chain call you still need to handle the logic to receive a cross-chain call when it sent from another blockchain to your Soroban contract.

When an inbound message is received, an Axelar Relayer will look to trigger the execute() function on your contract. To implement the execute functionality you can will implement the AxelarExecutableInterface trait to handle the execute functionality.

Before defining your new trait you must first make the trait’s dependency from the Axelar-CGP-Soroban repo available.

use axelar_gateway::executable::AxelarExecutableInterface;

Now you can implement trait AxelarExecutableInterface trait.

#[contractimpl]
impl AxelarExecutableInterface for AxelarGMP {
}

At this point your editor will be complaining that your implementation is missing the trait’s required functionality, gateway() and execute() functionality.

To resolve the first issue you can move the gateway() getter that you defined in the previous section of the contract to your new implementation.

To resolve the second issue you can paste in the signature for the execute() function as it’s defined in the original trait in the dependency.

fn execute(
env: Env,
source_chain: String,
message_id: String,
source_address: String,
payload: Bytes,
) {}

With the execute() signature now defined you can begin to write out the logic that will be used to handle the message.

The first thing you will want to do is to validate the incoming message. This can be done by triggering the third available function in the Axelar Trait, validate_message(). This function will confirm that the Gateway has received an approval from the Axelar verifier set that the message is in fact authentic. You pass it into let _ to tell the compiler that you do not care about the result of this function, to avoid the unused result warning.

let _ = Self::validate_message(&env, &source_chain, &message_id, &source_address, &payload);

With your message now validated you can emit an event to mark that your message was received at the destination chain.

For maximum modularity you can define the event in a separate event.rs file. You can go ahead and make several Soroban SDK types available in the new file.

use soroban_sdk::{Bytes, Env, String, Symbol};

Next you can define a new function called executed() where you can implement the event emission. This function will accept the same five parameters as the execute() function in the contract.rs file. The first thing you will want to do in the new function is define the event topics. Topics allow for filtering events, making it easier for off-chain applications to monitor specific changes within on-chain contracts. For your event’s topics you can pass in the following:

Note: The different names between the executed() function in the event.rs file with the execute() function in the contract.rs file.

let topics = (
Symbol::new(env, "executed"),
source_chain,
message_id,
source_address,
payload,
);

These topics include

  1. The name of the event, which is simply a symbol type of executed.
  2. The originating chain of the tx.
  3. A unique message id for the cross-chain message.
  4. The originating address of the cross-chain message.

With the topics now set you can emit your event using the publish function.

env.events().publish(topics, (payload,));

The publish() function requires the topics, which you previously defined as well as the payload, which contains the actual contents of the cross-chain message being emitted.

Your completed event.rs file should be as follows

use soroban_sdk::{Bytes, Env, String, Symbol};
pub fn executed(
env: &Env,
source_chain: String,
message_id: String,
source_address: String,
payload: Bytes,
) {
let topics = (
Symbol::new(env, "executed"),
source_chain,
message_id,
source_address,
);
env.events().publish(topics, (payload,));
}

The final thing you now must do is to make the code in the event.rs file as a mod in the lib.rs file.

mod event;

With your event now defined you may return to your contract.rs file to trigger it in your execute() function. First you must make the code from event.rs available:

use crate::event;

Now you can trigger the executed() function that will in turn trigger the executed event.

event::executed(&env, source_chain, message_id, source_address, payload);

At this point once your message is received on the destination chain your execute() function will ensure that it has been marked as approved on the gateway, then it will emit the executed() event along with the unique parameters to your cross-chain transaction that was sent to this Soroban contract!

Your gmp-example contract is now complete. If you run the command stellar contract build, your code should compile without any errors.

Edit on GitHub