Build your first XCDP - EVM Contract

Step by Step guide and Repository link provided in Templates Section. In this guide we will use Sepolia and bscTestnet to deploy the client chain contracts and L1X TestNet to deploy X-Talk Contract.

Building Blocks for XCDP

  1. Smart Contracts on Client Chains (example ethereum, binance chain) that will be the endpoints for sending and receiving messages. This is runtime agnostic.

  2. L1X X-Talk Contract that includes Client Chain Contract Registration Initialisation and X-Talk XCDP Contract Deployment and Initialisation to facilitate programmability while sending and receiving messages.

In this guide, we use Sepolia as Source and bscTestnet as Destination network.

Implementing Solidity Contract (Comments for Explanation)

Replace the DESTINATION_X-TALK_GATEWAY_CONTRACT_ADDRESS with respective contract address listed in this table.

//XCDPCore.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

/* XCDP Core to implement the interface.*/
contract XCDPCore {
    address public gatewayContractAddress = DESTINATION_X-TALK_GATEWAY_CONTRACT_ADDRESS; //Provided in endpoint
    mapping(bytes32 => XCDPReceiveMessage) public XCDPData;

/* Sending message instruction. You can make this
compatible with Destination or if you want to transform
the message on X-Talk you can send raw instructions and
prepare the payload on X-Talk Contract*/
    struct XCDPSendMessage {
        string message;
    }

/* Same with Receiving the message where you will be
able to provide any struct based on how you want to
receive it based on the transformed payload */
    struct XCDPReceiveMessage {
        string message;
    }

/* This is the encoded format of the paylaod to be 
sent. This will be sent to X-Talk Contract. Provide 
destination details here or add it into X-Talk if you
want to run logic and decide where to send the paylaod */
    event XTalkMessageBroadcasted(
        bytes message,
        string destinationNetwork,
        string destinationSmartContractAddress
    );



/* This is the function to be called when you want to 
send the message. The message is encoded into bytes 
before emitting */
    function _l1xSend(
        string memory message,
        string memory destinationSmartContractAddress, 
        string memory destinationNetwork
    ) external {



    XCDPSendMessage memory myMessage = XCDPSendMessage({
        message: message
    });



    // Convert the struct to bytes
    bytes memory messageBytes = abi.encode(myMessage.message);
        emit XTalkMessageBroadcasted(messageBytes, destinationNetwork, destinationSmartContractAddress);
    }

    /* decoding the message to retrieve the stringified message, 
    and storing it along with the global transaction id ( the message identifier ) */
    function _l1xReceive(bytes32 globalTxId, bytes memory message) external {
        require(msg.sender == gatewayContractAddress, "Caller is not xtalk node");
        XCDPReceiveMessage memory XCDPReceiveMessageData;
        (XCDPReceiveMessageData.message) = abi.decode(message, (string));
        XCDPData[globalTxId] = XCDPReceiveMessageData;
    }
}

Deploying the Solidity Contract (scripts/deploy.js)

 // deploy.js
 const hre = require("hardhat");

 async function main() {
     const [deployer] = await hre.ethers.getSigners();
 
     console.log(
         "Deploying contracts with the account:",
         deployer.address
     );
 
     // getBalance is a method on the provider, not the signer
     console.log("Account balance:", (await deployer.provider.getBalance(deployer.address)).toString());
 
     const XCDP = await hre.ethers.getContractFactory("XCDPCore");
     const XCDPContract = await XCDP.deploy([deployer.address]);
 
     await XCDPContract.waitForDeployment();
 
     console.log("XCDP contract deployed to:", await XCDPContract.getAddress());
 }
 
 main()
     .then(() => process.exit(0))
     .catch((error) => {
         console.error(error);
         process.exit(1);
 });

Hardhat Configuration JS

//hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");

// Example Only. Please use MPC or env
const PRIVATE_KEY = "YOUR_PRIVATE_KEY"; 


module.exports = {
  solidity: "0.8.24",
  networks: {
    bscTestnet: {
      url: "https://data-seed-prebsc-1-s1.binance.org:8545/", // BSC Testnet RPC URL
      chainId: 97,
      accounts: [PRIVATE_KEY] 
    },
    sepolia: {
      url: "https://rpc.sepolia.org", // Sepolia Testnet RPC URL
      accounts: [PRIVATE_KEY],
    },
  }
};

At this point, you will have the deployed contract addresses for sepolia and bscTestnet.

Implementing L1X X-Talk Flow Contract

Flow Contracts are at the core of decentralised multi-chain and cross-chain application development. They enable logic programmability, reducing integration and maintenance overhead and enable scalability.

Create a new L1X project that provides the necessary files to get started

cargo l1x create xcdp

Use the provided code below to implement the cross-chain xcdp logic. The X-Talk Flow Contract consists of fundamental building blocks which are re-usable across all development standards with X-Talk.

  1. Payload Definition that is being received. This could be deterministic or a generic vector of bytes. You can be deterministic or make this logic programmable.

  2. Saving the event data that will store the event that is received.

  3. Payload Logic that will allow you to implement logic based on what the destination payload required.

  4. Payload Transformation that will be based on the destination runtime.

  5. Emitting the Event to be sent to the Destination.

//lib.rs

// Import necessary crates and modules
use borsh::{BorshDeserialize, BorshSerialize};
use ethers::abi::Token;
use ethers::contract::EthEvent;
use l1x_sdk::{contract, store::LookupMap};
use serde::{Deserialize, Serialize};
// Define constants for storage keys
const STORAGE_CONTRACT_KEY: &[u8; 7] = b"message";
const STORAGE_EVENTS_KEY: &[u8; 6] = b"events";

// Import ethers utilities for ABI and event parsing
use ethers::{
    abi::{decode, ParamType},
    prelude::parse_log,
};

// Define data structures for messages
#[derive(Clone, Debug, BorshSerialize, BorshDeserialize, Serialize, Deserialize)]
pub struct XCDPSendMessage {
    message: String,
}

// This structure is used for solidity compatibility
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct XCDPSendMessageSolidity {
    message: String,
}

// Conversion trait to allow easy transformations between Solidity and custom Rust structs
impl From<XCDPSendMessageSolidity> for XCDPSendMessage {
    fn from(event: XCDPSendMessageSolidity) -> Self {
        Self {
            message: event.message,
        }
    }
}

// Define the event structure that this contract can parse and emit
#[derive(Clone, Debug, EthEvent, Serialize, Deserialize)]
#[ethevent(name = "XTalkMessageBroadcasted")]
pub struct XTalkMessageBroadcasted {
    message: ethers::types::Bytes,
    destination_network: String,
    destination_smart_contract_address: String,
}

// Payload structure for inter-chain messages
#[derive(Clone, Debug, BorshSerialize, BorshDeserialize, Serialize, Deserialize)]
pub struct Payload {
    data: Vec<u8>,
    destination_network: String,
    destination_contract_address: String,
}

// Main contract structure storing all event data
#[derive(BorshSerialize, BorshDeserialize)]
pub struct XCDPCore {
    events: LookupMap<String, XCDPSendMessage>,
    total_events: u64,
}

// Default constructor for the contract
impl Default for XCDPCore {
    fn default() -> Self {
        Self {
            events: LookupMap::new(STORAGE_EVENTS_KEY.to_vec()),
            total_events: u64::default(),
        }
    }
}

// Contract trait implementation containing all business logic
#[contract]
impl XCDPCore {
    // Function to load existing contract data from storage
    fn load() -> Self {
        match l1x_sdk::storage_read(STORAGE_CONTRACT_KEY) {
            Some(bytes) => match Self::try_from_slice(&bytes) {
                Ok(contract) => contract,
                Err(_) => panic!("Unable to parse contract bytes"),
            },
            None => panic!("The contract isn't initialized"),
        }
    }

    // Function to save contract state to storage
    fn save(&mut self) {
        match borsh::BorshSerialize::try_to_vec(self) {
            Ok(encoded_contract) => {
                l1x_sdk::storage_write(STORAGE_CONTRACT_KEY, &encoded_contract);
                log::info!("Saved event data successfully");
            }
            Err(_) => panic!("Unable to save contract"),
        };
    }

    // Constructor to initialize a new contract
    pub fn new() {
        let mut contract = Self::default();
        contract.save();
    }

    // Handler to process incoming events and save the decoded data
    pub fn save_event_data(event_data: Vec<u8>, global_tx_id: String) {
        l1x_sdk::msg(&format!(
            "********************global tx id {} **************",
            global_tx_id
        ));

        let mut contract = Self::load();

        log::info!("Received event data!!!");
        assert!(!global_tx_id.is_empty(), "global_tx_id cannot be empty");
        assert!(!event_data.is_empty(), "event_data cannot be empty");
        assert!(
            !contract.events.contains_key(&global_tx_id),
            "event is saved already"
        );

        let event_data = match base64::decode(event_data) {
            Ok(data) => data,
            Err(_) => panic!("Can't decode base64 event_data"),
        };

        let log: ethers::types::Log =
            serde_json::from_slice(&event_data).expect("Can't deserialize Log object");

        l1x_sdk::msg(&format!("{:#?}", log));
        let event_id = log.topics[0].to_string();
        if let Ok(standard_event) = parse_log::<XTalkMessageBroadcasted>(log.clone()) {
            let event = decode(&[ParamType::String], &standard_event.message).unwrap();

            let event = XCDPSendMessageSolidity {
                message: event[0].clone().into_string().unwrap(),
            };

            contract.save_standard_event(
                global_tx_id,
                event_id,
                event,
                standard_event.destination_smart_contract_address,
                standard_event.destination_network
            );
        } else {
            panic!("invalid event!")
        }

        contract.save()
    }

    fn save_standard_event(
        &mut self,
        global_tx_id: String,
        event_id: String,
        event: XCDPSendMessageSolidity,
        destination_contract_address: String,
        destination_network: String
    ) {
        let event_data = event.clone().into();
        let key = Self::to_key(global_tx_id.clone(), event_id);
        self.events.insert(key, event_data);

        let payload = Self::get_standard_payload(
            global_tx_id.clone(),
            destination_network,
            destination_contract_address,
            event.message.clone(),
        );
        l1x_sdk::msg(&format!("emitted event: {:?}", payload));
        l1x_sdk::emit_event_experimental(payload);
    }

    // Function to combine parts of an event into a single storage key
    pub fn to_key(global_tx_id: String, event_type: String) -> String {
        global_tx_id.to_owned() + "-" + &event_type
    }

    fn get_standard_payload(
        global_tx_id: String,
        destination_network: String,
        destination_contract_address: String,
        message: String,
    ) -> Payload {
        let message_encoded = ethers::abi::encode(&[Token::String(message)]);

        let signature = "_l1xReceive(bytes32,bytes)";
        let payload_encoded = ethers::abi::encode(&[
            Token::FixedBytes(hex::decode(global_tx_id).unwrap()),
            Token::Bytes(message_encoded),
        ]);

        let hash = ethers::utils::keccak256(signature.as_bytes());
        let selector = &hash[..4];
        let payload = [&selector[..], &payload_encoded[..]].concat();
        l1x_sdk::msg(&format!("payload --> {}", hex::encode(payload.clone())));
        return Payload {
            data: payload,
            destination_network,
            destination_contract_address,
        };
    }
}

The Fundamental Building Blocks of X-Talk require the below building blocks in X-Talk Contract.

  • Data Structures: Define messages and events used within the contract.

  • Event Handling: Parse, log, and handle blockchain events.

  • State Management: Load and save the contract's state to maintain event records.

  • Message Parsing: Convert blockchain event logs into usable data formats.

  • Event Emission and Logging: Emit structured logs for cross-chain messaging.

  • Security: Ensure that only valid and expected data is processed.

Ensure you have all the necessary packages, libraries and dependencies in your cargo.toml file as below.

[package]
name = "xcdp-core"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
borsh = { version = "0.9", features = ["const-generics"] }
l1x-sdk = "0.3.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "*"
ethers = "2.0.11"
getrandom = { version = "0.2.10", features = ["js"] }
hex = "0.4"
log = "0.4.20"

To deploy the X-Talk flow contract, you can use this command on your project level. After successfully building, you will find the "project_name.o" file in target/l1x/release folder. This is the object file which will be deployed.

cargo l1x build 

To deploy your contract, use an existing L1X Account with L1X for Mainnet and L1X TestNet for TestNet.

Create Wallet

Create your own wallet to generate a new keypair.

l1x-cli-beta wallet create

Import Wallet

Import you existing wallet using the l1x-cli-beta tool, by providing a private key. If you don't have one, Create Wallet.

l1x-cli-beta wallet import <PRIVATEKEY>

Default Wallet

After Importing your wallet set it to be the default interaction account.

l1x-cli-beta wallet default <Wallet_Address>

-- Check Endpoints in Interface Essentials for TestNet Faucet

Deploy your X-Talk Contract with l1x-cli-beta

l1x-cli-beta contract deploy /path/to/object/file --endpoint <https://v2-testnet-rpc.l1x.foundation>

Response expected:

Contract Deployed Successfully:
-----------------------------------------------------------------------
{
  "contract_address": "YOUR_CONTRACT_ADDRESS",
  "hash": "HASH"
}

To initiate your smart contract, use this command:

l1x-cli-beta contract init YOUR_CONTRACT_ADDRESS --endpoint <https://v2-testnet-rpc.l1x.foundation> --fee_limit 100000 

Response expected:

Contract Initialized Successfully:
-----------------------------------------------------------------------
{
  "contract_address": "YOUR_INSTANCE_ADDRESS",
  "hash": "HASH"
}

The instance address is your contract address which you can use to call your functions and interact.

Register Client Chain and X-Talk Contracts with X-Talk Node

X-Talk Nodes allow for data listening and send to the X-Talk Flow contract.

To register your addresses to the source registry, you need to call this:

l1x-cli-beta contract call SOURCE_REGISTRY_INSTANCE_ADDRESS register_new_source --args "{\\"destination_contract_address\\": \\"<YOUR_FLOW_CONTRACT_ADDRESS>\\", \\
\\"source_contract_address\\": \\"EVM_ADDRESS_ON_SOURCE_CHAIN\\", \\
\\"source_chain\\": \\"SOURCE_NETWORK\\", \\
\\"event_filters\\": [\\"TOPIC_OF_YOUR_EVENT\\"]}" --endpoint <https://v2-testnet-rpc.l1x.foundation> --fee_limit 100000
SOURCE_REGISTRY_INSTANCE_ADDRESS => This is provided in below example
source_contract_address => This is the contract address you want to listen to, without '0x'
TOPIC_OF_YOUR_EVENT => Topic of the event
YOUR_FLOW_CONTRACT_ADDRESS => The initiated X-TalK Flow Contract Address

Request Example:

l1x-cli-beta contract call b97f1a3708ae6d7df6ada0c695ce29e8acef954e register_new_source --args "{\"destination_contract_address\": \"512645313ec01dce17f023f5f710a07f010649cc\", \"source_contract_address\": \"011Fd5aE974A75e702388Cb798c57403610d93d1\", \"source_chain\": \"sepolia\", \"event_filters\": [\"5c6877990d83003ae27cf7c8f1a9d622868080df757847943133b78663358e42\"]}" --endpoint https://v2-testnet-rpc.l1x.foundation --fee_limit 100000

To validate that your data has been registered to the registry:

l1x-cli-beta contract view SOURCE_REGISTRY_INSTANCE_ADDRESS get_sources_from --args "{ \"from_index\": \"0\" }" --endpoint https://v2-testnet-rpc.l1x.foundation

Response Example:

"{\\"index\\":1,\\"sources\\":[[{\\"destination_contract_address\\":\\"512645313ec01dce17f023f5f710a07f010649cc\\",\\"source_contract_address\\":\\"011Fd5aE974A75e702388Cb798c57403610d93d1\\",\\"source_chain\\":\\"sepolia\\",\\"event_filters\\":[\\"5c6877990d83003ae27cf7c8f1a9d622868080df757847943133b78663358e42\\"]},\\"Create\\"]]}"

You have successfully deployed your first Bridgeless Cross-Chain Application.

Send the Event

//scripts/sendEvent.js
const hre = require("hardhat");

const { ethers } = hre;

async function main() {
    const [deployer, user] = await ethers.getSigners();

    console.log("Deployer address:", deployer.address);

    const simpleSwap = await ethers.getContractFactory("XCDPCore");
    const simpleSwapAddress = "YOUR_SOURCE_CONTRACT_ADDRESS";
    const simpleSwapContract = simpleSwap.attach(simpleSwapAddress);

    
    let tx = await simpleSwapContract.connect(deployer)._l1xSend(
        "helloWorld",
        // Destination Address => BSC Testnet
        "YOUR_DESTINATION_CONTRACT_ADDRESS",
        "bsc"
    );

    await tx.wait();

    console.log("send message:", tx.hash);
}

main()
    .then(() => process.exit(0))
    .catch((error) => {
        console.error(error);
        process.exit(1);
    });

Check the Seoplia Testnet Transaction Log.

X-TALK GATEWAY CONTRACT ADDRESS

Below table contains a list of contract address that serve as an authenticated entry to interact with X-Talk from respective client chain network.

Last updated