Sui Packages

Sui is a blockchain that has been integrated with the Axelar Network via the Interchain Amplifier.

Sending messages between Sui and other blockchains follows similar patterns as GMP messages on other chains, such as EVM chains.

The core packages for Axelar’s integration with Sui can be found at the axelar-cgp-sui repository.

At the core of there are two main package involved in sending a GMP message, these are the Gateway Package and the Gas Service.

For all complete Sui GMP implementation check out this example

To interact with the example and send a GMP message checkout this script

Sending a message from Sui involves the following steps:

  • Register your transaction with the relayer discovery service via the register_transaction() function on the relayer discovery service.
  • Prepare the message via the prepare_message() function on the gateway.
  • Pay gas via the pay_gas() function on the gas service.
  • Send the message via the send_message function on the gateway.

The Gateway is the core contract that facilitates the sending and receiving of cross-chain messages to other chains via the Axelar Network.

A shared object that anyone can access and user to refer to the Gateway package.

public struct Gateway has key {
id: UID,
inner: Versioned,
}

The Gateway facilitates sending and receiving of cross-chain messages to other chains via the Axelar Network.

For sending a GMP message, the send_message() function needs to be triggered.

The send_message function triggers your cross-chain message from Sui to another blockchain via the Axelar Network. The send_message() function requires a MessageTicket struct to be passed in. To create the MessageTicket you can trigger the prepare_message function.

💡

The reason this process is in two steps is because the Gateway is an upgrade compatible contract. To ensure minimal roadblocks when upgrading the contract the functionality of the Gateway was broken up so that if the Gateway does get upgraded at some point, applications can always continue to call the logic of the V0 prepare_message() function and pass the ticket into the V2 version of the send_message(), this will minimize breaking changes when upgrading the contract.

public fun send_message(self: &Gateway, message: MessageTicket) {
let value = self.value!(b"send_message");
value.send_message(message, VERSION);
}

The prepare_message() function is used to create a MessageTicket struct that is required to send a GMP message. The intended behavior is for applications that wish to send calls to return the message_ticket and have their frontend send it for easier upgradability

It takes four parameters.

  1. channel: The channel that the message is being sent from.
  2. destination_chain: Name of the chain the message is being sent to.
  3. destination_address: Address on the destination chain the message is being sent to.
  4. payload: A vector<u8> representation of the cross-chain message being sent.
public fun prepare_message(
channel: &Channel,
destination_chain: String,
destination_address: String,
payload: vector<u8>,
): MessageTicket {
message_ticket::new(
channel.to_address(),
destination_chain,
destination_address,
payload,
VERSION,
)
}

Receiving a message involves two steps. The first is approving an incoming message as approved and the second involved executing the approved message.

To approve the message an Axelar relayer triggers the Gateway’s approve_message() function. Once the message is marked as approved, the approval is stored in the Gateway object. This will indicate that the message has been confirmed the Axelar Verifier set on the Axelar network itself.

entry fun approve_messages(self: &mut Gateway, message_data: vector<u8>, proof_data: vector<u8>) {
let value = self.value_mut!(b"approve_messages");
value.approve_messages(message_data, proof_data);
}

A live example of an approval transaction can be found here.

With the message now marked as approved the relayer will attempt to execute the message on your Sui contract. For this the relayer will first trigger the Gateway’s take_approved_function(). This function will confirmed that the message has already been approved and will then begin the message consumption process

public fun take_approved_message(
self: &mut Gateway,
source_chain: String,
message_id: String,
source_address: String,
destination_id: address,
payload: vector<u8>,
): ApprovedMessage {
let value = self.value_mut!(b"take_approved_message");
value.take_approved_message(
source_chain,
message_id,
source_address,
destination_id,
payload,
)
}

On your own package the Relayer Discovery will then look to call the function you specified for it to call in the register_transaction() flow. If you registered a function called execute() (as was done in this example) then you can implement the execute() function as follows.

The executable function will pass in the ApprovedMessage that was consumed by the Package’s Channel.

public fun execute(call: ApprovedMessage, singleton: &mut Singleton) {
let (_, _, _, payload) = singleton.channel.consume_approved_message(call);
event::emit(Executed { data: payload });
}

A live example of an execution transaction can be found here.

In Sui there is no ability to check immediate caller of a message (ie there is no msg.sender like in EVM development). What is available is a transaction.origin, which is the root caller of a transaction (similar to tx.origin in EVM development). To identify who the caller of a message is you can use Channels. The Channel is an object that an application first creates and this channel is the identifier for who is calling and receiving the message.

Channels allow for sending and receiving messages between Sui and other chains. In the context of sending a message the channel acts as a destination for the messages. When the message is sent the destination_id is compared against the id of the channel.

public struct Channel has key, store {
/// Unique ID of the channel
id: UID,
}

The id specifies the address of the application for the purpose of incoming and outgoing external calls. This id has to match the id of a shared object that is passed in the channel creation method. This shared object can easily be queried by the relayer to get call fullfillment information.

The consume_approved_message() will confirm that the message has been sent to the correct channel.

The function takes two parameters.

  1. channel: The channel that the message is being sent to.
  2. approved_message: The ApprovedMessage struct that is being sent to the destination chain, containing relevant parameters of the cross-chain message.
public fun consume_approved_message(channel: &Channel, approved_message: ApprovedMessage): (String, String, String, vector<u8>) {
let ApprovedMessage {
source_chain,
message_id,
source_address,
destination_id,
payload,
} = approved_message;
// Check if the message is sent to the correct destination.
assert!(destination_id == object::id_address(channel), EInvalidDestination);
(source_chain, message_id, source_address, payload)
}

For an example of how to receive an approved message on the destination chain see here

The ApprovedMessage contains the following parameters

  1. source_chain: The name of chain where the cross-chain message originated.
  2. message_id: The unique ID of the message
  3. source_address: The address on the source chain where the message originated.
  4. destination_id: The id of the channel that the message is being sent to.
  5. payload: A vector<u8> representation of the cross-chain message being sent.
public struct ApprovedMessage {
source_chain: String,
message_id: String,
source_address: String,
destination_id: address,
payload: vector<u8>,
}

The Gas Service handles cross-chain gas payment when making a GMP request.

When sending a GMP message before triggering the send_message() function on the Gateway, the pay_gas() must be triggered first to pay for the cross-chain transaction.

The pay_gas() allows users to pay for the entirety of the cross-chain transaction in a given token, it is triggered by either the channel or the user. If it is called by the user the sender will be set as the channel_id.

The pay_gas() takes five parameters.

  1. gas_service: The contract who’s storage is set to be updated.
  2. message_ticket: The ticket for the message being sent.
  3. coin: The coin being used to pay for the transaction.
  4. refund_address: The address to be refunded if too much gas is paid.
  5. params: Should be passed in as an empty value.

💡

The params argument exists to allow for future extensibility of the function. It is not currently used in the implementation.

public fun pay_gas<T>(
self: &mut GasService,
message_ticket: &MessageTicket,
coin: Coin<T>,
refund_address: address,
params: vector<u8>,
) {
self
.value_mut!(b"pay_gas")
.pay_gas<T>(
message_ticket,
coin,
refund_address,
params,
);
}

In Sui there is no arbitrary execution like in EVM chains, therefore unlike in other ecosystem there is no Executable Package to inherit from. To resolve this issue the Relayer Discovery Package is deployed to serve as a registry of Packages that can be invoked given a message.

To get added to this registry a deployed application on Sui will need to trigger the register_transaction() function on the Discovery Package.

public fun register_transaction(self: &mut RelayerDiscovery, channel: &Channel, tx: Transaction) {
// Get the mutable value associated with the "register_transaction" key.
let value = self.value_mut!(b"register_transaction");
// Retrieve the unique channel ID from the provided channel.
let channel_id = channel.id();
// Set the transaction for this channel in the registry.
value.set_transaction(channel_id, tx);
}

The following arguments are required to register a Package:

  1. channel: The channel that the Package is being registered to.
  2. tx: The details of the function to be executed when the Package is called from an Axelar relayer.

By registering the transaction with the discovery system under a specific channel, the relayer later knows exactly which transaction to run when it receives an approved message on that channel.

See here for an example of how to register a transaction with the Discovery Package.

The MessageTicket struct is a “hot potato” object designed to encapsulate all the necessary information for a remote contract call. It is meant to be created by a module and then returned to the frontend, which will submit it to the gateway. This design ensures that when the gateway package is upgraded, the application code (modules) does not require any changes, thereby promoting forward compatibility.

It contains the following fields:

  1. source_id: Purpose: Represents the address that created the ticket.
  2. destination_chain: Specifies the destination chain where the message is intended to be delivered.
  3. destination_address: Indicates the address of the destination contract on the destination chain.
  4. payload: Contains the serialized data for the remote contract call.
  5. version: Captures the version of the MessageTicket structure. By embedding a version number, the system can restrict which messages are sent or processed by future packages. This helps ensure that outdated or incompatible messages from earlier versions are not inadvertently processed after an upgrade.
public struct MessageTicket {
source_id: address,
destination_chain: String,
destination_address: String,
payload: vector<u8>,
version: u64,
}

Edit on GitHub