v1.2 (Beta - Developer Guide)

Using SimpleMessageTest to Understand the XTalk Protocol

1. Overview

Purpose

This document provides a comprehensive guide for developers on how to use the SimpleMessageTest.sol contract. It serves as a practical, hands-on example for sending and receiving cross-chain messages using the L1X XTalk protocol.

SimpleMessageTest.sol is a minimal, developer-friendly smart contract that demonstrates the core functionality of the XTalk protocol. It is designed to be a clear and lucid starting point for building more complex cross-chain applications.

Target Audience & Limitations

This guide is intended for developers building applications on EVM-compatible blockchains.

  • Current Scope: This version of the XTalk protocol and this example support EVM-compatible networks only.

  • Supported Test Networks:

    • BSC Testnet

    • Ethereum Sepolia

Core Concepts

The heart of the XTalk protocol is the XTalkBeacon contract. It acts as a cross-chain "post office," where your contract drops off messages for other chains (registerMessage) and receives incoming messages via a standardized callback (_xtalkMessageReceived).


2. Contract Anatomy

The SimpleMessageTest.sol contract is structured to clearly separate the mandatory XTalk protocol code from the customizable user logic. This is highlighted by the in-code comments.

A. XTalk Infrastructure

These are the parts you must include and configure correctly for your contract to be XTalk-compatible.

  • IXTalkBeacon Interface: A standard Solidity interface that defines how your contract will communicate with the deployed XTalkBeacon.

  • xtalkBeaconAddress State Variable & constructor:

    // The XTalkBeacon contract instance.
    address public immutable xtalkBeaconAddress;
    
    /**
     * @dev Constructor to set the address of the XTalkBeacon contract.
     */
    constructor(address _beaconAddress) {
        require(_beaconAddress != address(0), "Invalid beacon address");
        xtalkBeaconAddress = _beaconAddress;
    }

    This is a critical setup step. When you deploy your contract, you must provide the correct address of the XTalkBeacon for the specific blockchain you are deploying on. The contract then casts this address to the IXTalkBeacon interface when it needs to call a beacon function.

  • _xtalkMessageReceived Function:

    function _xtalkMessageReceived(bytes32 _messageId, /*...*/) public {
        require(msg.sender == address(xtalkBeacon), "Caller is not the XTalkBeacon");
        // ...
    }

    This is the mandatory callback function that the beacon executes to deliver a message to your contract.

    • Function Signature: The name and parameters must be exactly _xtalkMessageReceived(bytes32,address,uint32,uint32,bytes).

    • Security: It is crucial to include the require(msg.sender == address(xtalkBeacon), ...) check to ensure that only the legitimate beacon can call this function.

B. User-Defined Logic

This is the part of the contract that you customize to build your application's specific features.

  • Message Struct & receivedMessages Mapping:

    struct Message { /* ... */ }
    mapping(bytes32 => Message) public receivedMessages;

    In this example, we use these to store the data from a received message. For your application, you might process a token swap, mint an NFT, or update some other state.

  • MessageReceived Event: This event provides off-chain tracking and visibility for when your contract receives a message. Note that the event for sending a message is emitted by the XTalkBeacon contract itself.

  • invokeMessage Function: This function demonstrates how the user-defined logic prepares data and then hands it off to the XTalk Infrastructure to be sent.

    function invokeMessage(...) public {
        // 1. User-Defined: Prepare your message data
        uint32 messageType = 1;
        bytes memory messageData = abi.encode(_message);
    
        // 2. XTalk Infrastructure: Get nonce and register the message
        uint256 nonce = IXTalkBeacon(xtalkBeaconAddress).getSenderRegistrationNextNonce(address(this));
        IXTalkBeacon(xtalkBeaconAddress).registerMessage(...);
    
        // NO event is emitted here. The XTalkBeacon contract emits the
        // 'XTalkMessageBroadcasted' event, which should be used for tracking.
    }

3. How It Works: The Cross-Chain Flow

The following diagram illustrates the end-to-end journey of a message. The L1X Validator Network and the XTalk Consensus Contract on the L1X chain are the core components that make this possible.

sequenceDiagram
    participant User
    participant SMT_Source as SimpleMessageTest (Source Chain)
    participant Beacon_Source as XTalkBeacon (Source Chain)
    participant L1X_Network as L1X Validator Network
    participant Consensus_Contract as XTalk Consensus (L1X)
    participant Beacon_Dest as XTalkBeacon (Destination Chain)
    participant SMT_Dest as SimpleMessageTest (Destination Chain)

    User->>+SMT_Source: invokeMessage(...)
    SMT_Source->>+Beacon_Source: registerMessage(...)
    note right of Beacon_Source: Emits 'XTalkMessageBroadcasted'<br/>(messageId is retrieved here)
    Beacon_Source-->>-SMT_Source:
    SMT_Source-->>-User:

    L1X_Network->>Beacon_Source: Observes event on Source Chain
    L1X_Network->>+Consensus_Contract: Reaches consensus on the message
    note over L1X_Network, Consensus_Contract: Validators sign the message payload

    User->>Consensus_Contract: (Optional) get_consensus_status(messageId)
    Consensus_Contract-->>User: Returns signer count, status, etc.

    L1X_Network->>+Beacon_Dest: Relays message with signatures
    Beacon_Dest->>+SMT_Dest: _xtalkMessageReceived(...)
    note right of SMT_Dest: Executes user logic
    SMT_Dest-->>-Beacon_Dest:
    Beacon_Dest-->>-L1X_Network:

The process unfolds in these steps:

  1. Invocation (Source Chain): A user calls invokeMessage on the SimpleMessageTest contract.

  2. Registration (Source Chain): The contract calls registerMessage on the source chain's XTalkBeacon, which emits the XTalkMessageBroadcasted event containing a unique messageId.

  3. Consensus (L1X Network): The L1X network validators observe this event. They process the message, and a sufficient number of them sign it, reaching consensus. This status is recorded in the XTalk Consensus Contract on the L1X chain.

  4. Execution (Destination Chain): Once consensus is reached, the validators relay the message and the collected signatures to the XTalkBeacon on the destination chain. The beacon verifies the signatures and calls _xtalkMessageReceived on the target contract, completing the cross-chain transaction.


4. Practical Guide: Deployment and Interaction

Supported Networks & Chain IDs

List the currently supported test networks and their corresponding Chain IDs, which you will need for the _destinationChainId parameter when sending a message.

  • BSC Testnet:

    • Chain ID: 97

  • Ethereum Sepolia:

    • Chain ID: 11155111

Deployment

  1. Compile: Use a Solidity compiler (version ^0.8.0 or compatible) to compile SimpleMessageTest.sol.

  2. Deploy: Deploy the compiled contract to a supported network (e.g., BSC Testnet or ETH Sepolia).

  3. Provide Beacon Address: During deployment, you must provide the correct XTalkBeacon address for that network in the constructor.

    • BSC Testnet XTalkBeacon Address: 0x283217CFb842A235994397a36714eF90F7744B51

    • ETH Sepolia XTalkBeacon Address: 0x8cA818D30c01AD6ebD2273CC419553F38fE7c92f

Sending a Message

To send a message, call the invokeMessage function on your deployed contract.

  • _destinationChainId: The unique identifier for the destination chain. Use 97 for BSC Testnet or 11155111 for Ethereum Sepolia.

  • _destinationAddress: The address of the recipient contract on the destination chain, formatted as bytes32.

  • _message: The string message you want to send.

Confirming a Message on the Destination Chain

  1. When you call invokeMessage, listen for the XTalkMessageBroadcasted event on the XTalkBeacon contract. It contains the messageId.

  2. After a short delay for the L1X Network to relay the message, you can check for its arrival on the destination chain.

  3. Call the receivedMessages public mapping on the destination contract with the messageId to retrieve the stored message data and confirm its receipt.

Tracking Message Status on the L1X Network

The ultimate source of truth for a message's journey is the XTalk Consensus Contract deployed on the L1X Network. You can query this contract directly using the messageId to see its current consensus status, including how many validators have signed it.

The address for this contract on L1X Testnet v2 is:

  • XTalk Consensus Contract: ce527bdc3eeaa201a2706ab2e447fc7f0db5cb68

The following script shows how to do this using the @l1x/l1x-wallet-sdk.

// Example script to check the consensus status of a message on the L1X Network.
// This requires the `@l1x/l1x-wallet-sdk`. You can install it via npm:
// npm install @l1x/l1x-wallet-sdk
const { L1XProvider } = require("@l1x/l1x-wallet-sdk");

// The address of the XTalk Consensus Contract on the L1X Network
const CONSENSUS_CONTRACT_ADDRESS = "ce527bdc3eeaa201a2706ab2e447fc7f0db5cb68";

/**
 * Initializes and returns an L1XProvider instance for the L1X V2 Testnet.
 * @returns {L1XProvider}
 */
function getL1XProvider() {
    // For mainnet, change clusterType to "mainnet" and use the mainnet RPC endpoint.
    return new L1XProvider({
      clusterType: "testnet",
      endpoint: "https://v2-testnet-rpc.l1x.foundation"
    });
}

/**
 * Queries the L1X Consensus Contract for the status of a specific message.
 * @param {string} messageId The message ID obtained from the 'XTalkMessageBroadcasted' event.
 * @returns {Promise<object>} The consensus status of the message.
 */
async function checkL1XConsensusStatus(messageId) {
    console.log(`\nQuerying L1X for status of message: ${messageId}`);

    try {
        // Initialize the L1X provider
        const l1xProvider = getL1XProvider();

        // Make a read-only call to the smart contract
        const responseJson = await l1xProvider.core.makeReadOnlyCall({
            contract_address: CONSENSUS_CONTRACT_ADDRESS,
            function_name: "get_consensus_status",
            args: {
                message_id: messageId
            },
        });
        
        // The response is a JSON string, so we parse it
        return JSON.parse(responseJson);

    } catch (error) {
        console.error("Error fetching consensus status:", error);
        throw error;
    }
}

// --- Main execution ---
(async () => {
    // Replace with the messageId you want to track
    const messageIdToTrack = "0xffd6da09d5047e7ce19bf818e0d93c11a0bd2fc79ec80c18a523569f6a902973";
    
    const status = await checkL1XConsensusStatus(messageIdToTrack);
    
    console.log("\n--- L1X Consensus Status ---");
    console.log(JSON.stringify(status, null, 2)); // Pretty-print the status object
})();

Understanding the Consensus Status Response

The JSON object returned by get_consensus_status represents the complete state of a cross-chain message as it moves through the L1X consensus process. Here is a breakdown of each field:

Validator and Confirmation Counts

These fields provide an overview of the validator set and the progress of votes for this specific message.

  • total_listener_validators: The total number of registered "Listener" validators at the time the message was processed. Listeners are responsible for observing and confirming the initial event on the source chain.

  • total_signer_validators: The total number of registered "Signer" validators. Signers are responsible for cryptographically signing the message payload that will be executed on the destination chain.

  • total_relayer_validators: The total number of registered "Relayer" validators. Relayers are responsible for broadcasting the transaction to the destination chain and confirming its final state.

  • agreed_listener_validators: The current count of Listener validators who have successfully voted and agreed on the validity of the source chain event.

  • agreed_signer_validators: The current count of Signer validators who have successfully voted on and signed the destination payload.

  • agreed_relayer_confirmations: The current count of Relayer validators who have confirmed that the transaction was successfully executed on the destination chain.

  • disagreed_relayer_confirmations: The current count of Relayer validators who have reported that the transaction failed on the destination chain.

Lifecycle and Timestamps

These fields track the message's current stage and when key milestones were reached.

  • consensus_stage: The current stage of the message. Possible values are:

    • 'Pending': The message has not yet reached consensus in any phase.

    • 'ListenerFinalized': The Listeners have reached consensus on the source chain event.

    • 'SignerFinalized': The Signers have reached consensus on the destination payload.

    • 'RelayerBroadcasted': The Relayers have successfully broadcasted the transaction to the destination chain.

    • 'RelayerFinalized': The Relayers have confirmed the final status (success or failure) of the destination transaction.

  • listener_finalized_timestamp: A Unix timestamp (in milliseconds) of when the Listener consensus was reached.

  • signer_finalized_timestamp: A Unix timestamp (in milliseconds) of when the Signer consensus was reached.

  • relayer_finalized_timestamp: A Unix timestamp (in milliseconds) of when the Relayer consensus was reached.

    • Note on relayer_finalized_timestamp: This field is reserved for future protocol enhancements where relayers explicitly confirm the final on-chain status (e.g., after a certain number of block confirmations). In the current implementation, the most reliable indicator that a message has been successfully passed to the destination chain is the presence of a non-empty destination_tx_hash.

Message Payload and Routing

These fields contain the core data of the cross-chain message and the necessary information to route it correctly.

  • signer_payload: The raw byte payload that the Signer validators have agreed upon. This is the exact data passed to the XTalkBeacon on the destination chain.

  • signer_signatures: A list of the raw, recoverable signatures from the Signer validators. These are submitted to the destination XTalkBeacon to prove the payload's authenticity.

Transaction and Address Details

These fields provide the specific identifiers and addresses involved in the cross-chain transaction.

  • source_network_id: The chain ID of the network where the message originated.

  • source_tx_hash: The transaction hash of the initial call on the source chain.

  • flow_contract_address: The address of an intermediary "flow" contract on the L1X network that may have processed the event data.

  • source_contract_address: The address that emitted the event on the source chain (i.e., the XTalkBeacon).

  • destination_tx_hash: The transaction hash of the execution call on the destination chain, as reported by the first relayer.

  • destination_network_id: The chain ID of the target network.

  • destination_contract_address: The address of the target contract on the destination chain.

  • destination_relayer_address: The address of the relayer validator that broadcast the transaction.


5. Next Steps

This SimpleMessageTest contract and its usage guide are designed to be your launchpad. You can use this contract as a boilerplate for your own projects. By replacing the user-defined logic, you can build powerful cross-chain applications such as:

  • Cross-chain token swaps

  • Multi-chain NFT marketplaces

  • Decentralized governance across multiple networks


6. Example: Standalone Ethers.js Script

This example shows how to interact with the contract in a standalone Node.js script using the ethers library (v6), without relying on a framework like Hardhat.

Important Note: Listening for the Correct Event

When you call invokeMessage, your contract does not emit an event. The event is emitted by the XTalkBeacon contract that invokeMessage calls. You must listen to the XTalkBeacon contract for the XTalkMessageBroadcasted event to get the messageId and confirm the message was sent.

Important Note: Address to bytes32 Conversion

The _destinationAddress parameter in the invokeMessage function expects a bytes32 value. However, standard Ethereum addresses are 20-byte values. You must pad the 20-byte address with leading zeros to convert it to a 32-byte value. With ethers.js, you can do this easily:

const destinationAddress = "0x..."; // Your 20-byte destination address
const destinationAddressBytes32 = ethers.zeroPadValue(destinationAddress, 32);

Sample Interaction Script

This script shows a complete flow: setting up a connection, sending a message, and retrieving the messageId by listening to the XTalkBeacon contract's events.

// A standalone script to interact with the SimpleMessageTest contract using ethers.js v6
const { ethers } = require("ethers");

// ======== 1. Configuration ========

// Provider setup: Replace with your RPC URL (e.g., from Infura, Alchemy)
const provider = new ethers.JsonRpcProvider("https://sepolia.infura.io/v3/YOUR_PROJECT_ID");

// Signer setup: Replace with the private key of the account you want to send the transaction from
const privateKey = "0x..."; // WARNING: Do not hardcode private keys in production. Use environment variables.
const wallet = new ethers.Wallet(privateKey, provider);

// Your contract details
const simpleMessageTestAddress = "0x..."; // Address of your deployed SimpleMessageTest contract
const simpleMessageTestAbi = [
    // We only need the function signature for the function we are calling
    "function invokeMessage(uint32 _destinationChainId, bytes32 _destinationAddress, string memory _message)"
];

// XTalkBeacon contract details
const beaconAddress = "0x8cA818D30c01AD6ebD2273CC419553F38fE7c92f"; // The XTalkBeacon address for Sepolia
const beaconAbi = [
    // The ABI for the event we want to listen for
    "event XTalkMessageBroadcasted(bytes32 indexed messageId, uint32 sourceChainId, uint32 destinationChainId, uint32 messageType, address indexed sourceInvokerAddress, bytes32 destinationAddress, uint256 timestamp, bytes messageData)"
];

// Create contract instances
const simpleMessageContract = new ethers.Contract(simpleMessageTestAddress, simpleMessageTestAbi, wallet);
const beaconContract = new ethers.Contract(beaconAddress, beaconAbi, provider);

// ======== 2. Interaction Parameters ========
const destinationChainId = 97; // e.g., BSC Testnet
const destinationAddress = "0x..."; // Address of the recipient contract on the destination chain
const message = "Hello from a standalone ethers.js script!";


async function main() {
    console.log(`Sending transaction from: ${wallet.address}`);
    
    // 3. Convert the 20-byte destination address to bytes32
    const destinationAddressBytes32 = ethers.zeroPadValue(destinationAddress, 32);

    // 4. Invoke the message by calling the contract function
    console.log(`Invoking message on contract at ${simpleMessageTestAddress}...`);
    const tx = await simpleMessageContract.invokeMessage(
        destinationChainId,
        destinationAddressBytes32,
        message
    );
    
    console.log("Transaction sent! Hash:", tx.hash);
    const receipt = await tx.wait();
    console.log(`Transaction confirmed in block: ${receipt.blockNumber}`);

    // 5. Find and parse the 'XTalkMessageBroadcasted' event from the transaction receipt
    //    We must look for this event from the BEACON's address in the logs.
    const eventTopic = beaconContract.interface.getEvent("XTalkMessageBroadcasted").topicHash;
    const log = receipt.logs.find(l => l.address === beaconAddress && l.topics[0] === eventTopic);
    
    if (log) {
        const parsedLog = beaconContract.interface.parseLog(log);
        const { messageId } = parsedLog.args;
        console.log(`\n✅ Message Broadcasted via Beacon!`);
        console.log(`  - Message ID: ${messageId}`);
        console.log(`  - Use this ID to track the message status on the destination chain.`);
    } else {
        console.log("Could not find 'XTalkMessageBroadcasted' event in the transaction receipt.");
    }
}

main().catch((error) => {
    console.error("Script failed:", error);
    process.exit(1);
});

Last updated