Creating Cross-Chain Transactions using Across+

The blockchain ecosystem is fragmented with numerous independent networks operating in isolation. This lack of interoperability presents significant challenges such as fragmented liquidity between different networks and complex user experience.

Cross-chain transactions enable the seamless transfer of assets or data across these networks, creating an interconnected blockchain ecosystem. Across+ provides developers a bridging solution, offering a powerful yet friendly framework to facilitate these transactions efficiently and securely.

In this article, we will explore the steps to creating cross-chain transactions using Across+, understanding its architecture framework. Also, we will demonstrate how to create a smart contract and interact with it from multiple chains with a Next.js application.

Table of Contents

  1. The Interoperability Challenge

    1. Across’ Intents Architecture

    2. How Intents Work

    3. Understanding Cross-Chain Transactions

  2. Implementing Across+

    1. Choosing a Compatible Blockchain

    2. Creating the Smart Contract

  3. Building the Application

    1. Prerequisites

    2. Setting Up the Next.js Project

    3. Install Required Dependencies

    4. Integrating Web3 Onboard

    5. Set Up Web3 Onboard in Next.js

    6. Creating an Across+ Transaction

  4. Summary

The Interoperability Challenge

Blockchains have introduced a unique way to establish truth without the need for trust. However, one significant consequence of their decentralized design is that they exist as isolated domains, incapable of direct communication with one another.

The Across Protocol addresses the blockchain interoperability challenge by employing an intents-based architecture, offering a unique approach to facilitating seamless cross-chain interactions. Here’s how Across solves the interoperability issues:

Across’ Intents Architecture

The Across Protocol addresses the interoperability challenge by employing an intents based architecture, offering an approach to facilite seamless cross-chain interactions.

What are Intents?

“An intent is a type of order where a user specifies an outcome instead of an execution path. Intents can be single-chain, or cross-chain where the user's desired outcomes is on a different chain than input assets. Across is only focused on cross-chain intents.

How Intents Work

  1. User Intent Submission: Users submit an intent specifying the desired outcome on a different blockchain.

  2. Relayer Network: Competitive relayers bid to fulfill these intents, providing the best rates and fastest execution.

  3. Settlement Layer: The system escrows funds, verifies completion, and ensures relayers are paid upon successful execution.

This architecture ensures efficient and secure cross-chain transactions, overcoming the limitations of isolated blockchain domains.

Understanding Cross-Chain Transactions

A cross-chain transaction is an operation that transfer assets or data from one blockchain to another. These transactions are essential for enhancing liquidity, enabling interoperability, and providing user flexibility across different blockchain networks.

By moving assets between chains, users can take advantage of unique features and benefits of multiple blockchains without being confined to a single network.

What Does a Cross-Chain Transaction Involve?

A cross-chain transaction in Across Protocol involves several key steps to ensure secure and efficient asset transfer and data across blockchains:

  1. Initiation: A user starts the transaction by depositing into the designated pool, called SpokePool, on the source blockchain. The user provides specific instructions, including the destination blockchain and the desired outcome.

  2. Verification: The details of the deposit are verified to ensure is everything is correct. This steps ensures that the transaction is legitimate and ready to be processed.

  3. Relayer Execution: Competitive relayers, acting as intermediaries, bid to fulfill the user’s intent. Once a relayer is selected, they immediately provide the equivalent asset to the user on the destination blockchain, minus any applicable fees. The user receives their funds on the new chain, completing their interaction with the protocol.

  4. Proof Submission: After executing the transaction, the relayer submits a proof of the relay and the original deposit to UMA’s optimistic oracle (OO). This proof ensures that the transaction was correctly executed.

  5. Settlement: Once an intent has been fulfilled, Across verifies the fulfillment and releases the input tokens to the relayer. Across periodically verifies a bundle of intents by determining a block range, validating events, aggregating payments, including transfer instructions, organizing data into Merkle trees, and executing the bundle after a challenge period. This ensures accurate and efficient reimbursement of the relayer.

Implementing Across+

Implementing cross-chain transactions with Across+ involves steps to ensure seamless and secure asset transfers between different blockchain networks. Here’s a high-level overview of the process:

Choosing a Compatible Blockchain

Currently, Across only supports the following blockchain networks:

  • Mainnet

  • Arbitrum

  • Optimism

  • Base

  • Linea

  • zkSync

  • Polygon

For the specific contract addresses of the spoke pools on each supported blockchain, refer to the Across+ Documentation.

Creating the Smart Contract

To implement cross-chain transactions with Across+, you need to create a smart contract that can handle incoming messages and process them accordingly. This involves building a handler contract that will execute specific actions when it receives a message from the Across SpokePool.

You will need to implement a function matching the following interface in your handler contract to receive the message:

function handleV3AcrossMessage(
    address tokenSent,
    uint256 amount,
    address relayer,
    bytes memory message
) external;

Here’s an example of a handler contract that only emits an event when a message is received. You can find this contract deployed in Optimism.

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

contract HandleMessageContract {

    error Unauthorized();

    address public immutable acrossSpokePool;
    
    constructor(address _acrossSpokePool) {
        acrossSpokePool = _acrossSpokePool;
    }

    event MessageHandled(
        address indexed tokenSent,
        uint256 amount,
        address indexed relayer,
        bytes message
    );

    function handleV3AcrossMessage(
        address tokenSent,
        uint256 amount,
        address relayer,
        bytes memory message
    ) external {
        if (msg.sender != acrossSpokePool) revert Unauthorized();
        emit MessageHandled(tokenSent, amount, relayer, message);
    }
}

When deploying your contract to the blockchain, ensure you accurately specify the acrossSpokePool address corresponding to the particular chain where your contract will reside.

This is crucial because the designated acrossSpokePool will be the entity invoking the handleV3AcrossMessage function.

To obtain the correct acrossSpokePool address for your target chain, refer to the official Across+ documentation or the relevant network's deployment details. This precision is essential to ensure your contract interacts correctly with the Across+ infrastructure and securely handles cross-chain transactions.

Building the Application

For the example in this article, we are using Next.js, Web3 Onboard and ethers for interacting with the contract. This section will guide you through setting up a Next.js application and integrating it with Web3 Onboard to interact with your deployed smart contract.

You can find all the code used in this GitHub repository.

Prerequisites

Setting Up the Next.js Project

First, create a new Next.js project by running the following command:

npx create-next-app@latest crosschain-executor
cd crosschain-executor

Install Required Dependencies

Next, install the necessary dependencies for interacting with your smart contract an integrating Web3 Onboard:

npm install ethers@5.7.2 @web3-onboard/core@2.22.0 @web3-onboard/injected-wallets@2.8.5 @web3-onboard/react@2.8.11

or alternatively using pnpm:

pnpm install ethers@5.7.2 @web3-onboard/core@2.22.0 @web3-onboard/injected-wallets@2.8.5 @web3-onboard/react@2.8.11

These packages include:

  • ethers for interacting with the blockchain.

  • @web3-onboard for connecting our wallets inside our Next.js application.

Integrating Web3 Onboard

To set up Web3 Onboard with all the chains currently supported by Across, follow these steps. This configuration will allow seamless interaction with Ethereum, Optimism, Polygon, zkSync, Base, Arbitrum One, and Linea networks through a single configuration file.

To begin with the integration, create a new file named web3-onboard.ts in the root directory:

import injectedModule from '@web3-onboard/injected-wallets'

import { init } from '@web3-onboard/react'

const injected = injectedModule();

export default init({
  wallets: [
    injected
  ],
  chains: [
    {
      id: 1,
      namespace: 'evm',
      token: 'ETH',
      label: 'Ethereum',
      rpcUrl: `https://eth.llamarpc.com`
    },
    {
      id: 10,
      token: 'ETH',
      label: 'Optimism',
      rpcUrl: `https://optimism.llamarpc.com`
    },
    {
      id: 137,
      token: 'MATIC',
      label: 'Polygon',
      rpcUrl: 'https://matic-mainnet.chainstacklabs.com'
    },
    {
      id: 324,
      token: 'ETH',
      label: 'zkSync',
      rpcUrl: 'https://zksync-era.blockpi.network/v1/rpc/public'
    },
    {
      id: 8453,
      token: 'ETH',
      label: 'Base',
      rpcUrl: 'https://base.llamarpc.com'
    },
    {
      id: 42161,
      token: 'ETH',
      label: 'Arbitrum One',
      rpcUrl: `https://arbitrum.llamarpc.com`
    },
    {
      id: 59144,
      token: 'ETH',
      label: 'Linea',
      rpcUrl: 'https://linea.blockpi.network/v1/rpc/public'
    },
  ],
  appMetadata: {
    name: 'Crosschain Executor',
    icon: '<svg></svg>',
    logo: '<svg></svg>',
    description: 'Demo app for Across+',
    gettingStartedGuide: 'https://github.com/saugardev/crosschain-executor/blob/main/README.md',
    explore: 'https://github.com/saugardev/crosschain-executor',
    recommendedInjectedWallets: [
      {
        name: 'MetaMask',
        url: 'https://metamask.io'
      },
    ],
  }
})

Set Up Web3 Onboard in Next.js

Create a new file named web3-provider.tsx in the providers directory:

"use client";

import web3Onboard from '@/web3-onboard'
import { Web3OnboardProvider } from '@web3-onboard/react'

export default function Web3Provider({ children }: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <Web3OnboardProvider web3Onboard={web3Onboard}>
      {children}
    </Web3OnboardProvider>
  );
}

Modify the layout.tsx file to include the Web3Provider we just created:

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Web3Provider from "@/providers/web3-provider";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Crosschain Executor",
  description: "Demo app for Across+",
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="en">
      <Web3Provider>
        <body className={inter.className}>{children}</body>
      </Web3Provider>
    </html>
  );
}

Lastly update the page.tsx use Web3 Onboard:

'use client'

import { useConnectWallet } from '@web3-onboard/react'
import { ethers } from 'ethers'

export default function Home() {
  const [{ wallet, connecting }, connect, disconnect] = useConnectWallet();

  let ethersProvider;

  if (wallet) {
    ethersProvider = new ethers.providers.Web3Provider(wallet.provider, 'any');
  }
  
  return (
    <main className="min-h-screen py-16 flex flex-col justify-center items-center flex-1">
      <h1 className="m-0 leading-tight text-4xl">
        Crosschain Executor{' '}
      </h1>

      <button
        className='rounded-md bg-gray-900 border-none text-lg font-semibold cursor-pointer text-white px-3.5 py-3 mt-10'
        disabled={connecting}
        onClick={() => (wallet ? disconnect(wallet) : connect())}
      >
        {connecting ? 'Connecting' : wallet ? 'Disconnect' : 'Connect'}
      </button>
    </main>
  )
}

After doing all this steps it should look like this and we should be able to connect our wallet. If we run the following command we should be able to connect our wallet with our injected provider.

npm run dev

Creating an Across+ Transaction

This process involves obtaining a quote, crafting a message, and creating a deposit by interacting with the SpokePool contract. We'll create an abstraction of the API to request the quote, and we will create a component that calls the SpokePools of the current chain set up from Web3 Onboard.

Setting Up the Constants

First, we define the constants for the addresses and SpokePools contracts, which will be used in our component.Create the file across.ts in the constants folder:

export type ChainId = 1 | 10 | 137 | 324 | 8453 | 42161 | 59144;

export const WethContracts: { [key in ChainId]: string } = {
  1: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',      // Mainnet 
  10: '0x4200000000000000000000000000000000000006',     // Optimism
  137: '0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619',    // Polygon
  324: '0x5aea5775959fbc2557cc8789bc1bf90a239d9a91',    // zkSync
  8453: '0x4200000000000000000000000000000000000006',   // Base
  42161: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1',  // Arbitrum
  59144: '0x4200000000000000000000000000000000000006',  // Linea
};

export const SpokePoolsContracts: { [key in ChainId]: string } = {
  1: '0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5',     // Mainnet
  10: '0x6f26Bf09B1C792e3228e5467807a900A503c0281',    // Optimism
  137: '0x9295ee1d8C5b022Be115A2AD3c30C72E34e7F096',   // Polygon
  324: '0xE0B015E54d54fc84a6cB9B666099c46adE9335FF',   // zkSync
  8453: '0x09aea4b2242abc8bb4bb78d537a67a245a7bec64',  // Base
  42161: '0xe35e9842fceaca96570b734083f4a58e8f7c5f2a', // Arbitrum
  59144: '0x7e63a5f1a8f0b4d0934b2f2327daed3f6bb2ee75', // Linea
};

export const spokePoolAbi = [
  {
    "inputs": [
      { "internalType": "address", "name": "depositor", "type": "address" },
      { "internalType": "address", "name": "recipient", "type": "address" },
      { "internalType": "address", "name": "inputToken", "type": "address" },
      { "internalType": "address", "name": "outputToken", "type": "address" },
      { "internalType": "uint256", "name": "inputAmount", "type": "uint256" },
      { "internalType": "uint256", "name": "outputAmount", "type": "uint256" },
      { "internalType": "uint256", "name": "destinationChainId", "type": "uint256" },
      { "internalType": "address", "name": "exclusiveRelayer", "type": "address" },
      { "internalType": "uint32", "name": "quoteTimestamp", "type": "uint32" },
      { "internalType": "uint32", "name": "fillDeadline", "type": "uint32" },
      { "internalType": "uint32", "name": "exclusivityDeadline", "type": "uint32" },
      { "internalType": "bytes", "name": "message", "type": "bytes" }
    ],
    "name": "depositV3",
    "outputs": [],
    "stateMutability": "payable",
    "type": "function"
  }
];

export const handlerContract = "0x87aeda878969075de0b4aab1e493bd2a22ee39dd";
export const handlerContractChainId: ChainId = 10;

Abstracting the API

We create an API file to handle the request for getting suggested fees. Create a new file across-api.ts in the lib the folder.

export interface SuggestedFeesRequest {
  token?: string;
  inputToken: string;
  outputToken: string;
  originChainId: number;
  destinationChainId: number;
  amount: number;
  recipient?: string;
  message?: string;
  relayer?: string;
  timestamp?: number;
}

export async function getSuggestedFees(params: SuggestedFeesRequest) {
  const baseUrl = 'https://app.across.to/api/suggested-fees';
  const url = new URL(baseUrl);
  
  Object.entries(params).forEach(([key, value]) => {
    if (value !== undefined) {
      url.searchParams.append(key, String(value));
    }
  });

  const response = await fetch(url.toString());
  if (!response.ok) {
    throw new Error(`Failed to fetch data from ${url.pathname}: ${response.statusText}`);
  }
  
  return await response.json();
}

Implementing the Component

Now, let's implement the component that will use the constants and API abstraction to create a cross-chain transaction.

Create the file cross-plus.tsx on the components folder:

'use client'

import { useState, useEffect } from 'react';
import { BigNumber, ethers } from 'ethers';
import { useConnectWallet } from '@web3-onboard/react';
import { getSuggestedFees } from '@/lib/across-api';
import { 
  ChainId,
  WethContracts, 
  SpokePoolsContracts,
  spokePoolAbi,
  handlerContractChainId,
  handlerContract
} from '@/constants/across';

const AcrossPlusComponent = () => {
  const [{ wallet }] = useConnectWallet();
  const [loading, setLoading] = useState(false);
  const [originChainId, setOriginChainId] = useState<ChainId>();
  const [inputToken, setInputToken] = useState<string>();
  const [spokePoolAddress, setSpokePoolAddress] = useState<string>();

  useEffect(() => {
    if (wallet) {
      const provider = new ethers.providers.Web3Provider(wallet.provider);
      provider.getNetwork().then((network) => {
        const chainId = network.chainId as ChainId;
        setOriginChainId(chainId);
        setInputToken(WethContracts[chainId]);
        setSpokePoolAddress(SpokePoolsContracts[chainId]);
      });
    }
  }, [wallet]);

  const handleDeposit = async () => {
    if (wallet === null) {
      alert("Please connect your wallet.");
      return;
    }
    if (originChainId === undefined || spokePoolAddress === undefined) {
      alert("Unable to determine network.");
      return;
    }
    if (inputToken === undefined) {
      alert("Unable to determine input token.");
      return;
    }
    if (spokePoolAddress === undefined) {
      alert("Unable to determine spoke pool address.");
      return;
    }
    const provider = new ethers.providers.Web3Provider(wallet.provider);
    const signer = provider.getSigner();


    try {
      setLoading(true);

      // Getting a Quote
      const feeData = await getSuggestedFees({
        originChainId,
        inputToken,
        outputToken: WethContracts[10],
        destinationChainId: handlerContractChainId,
        amount: 1000000000000000,
      });
      const totalRelayFee = feeData.totalRelayFee.total;

      // Crafting the message
      const abiCoder = new ethers.utils.AbiCoder();
      const encodedMessage = abiCoder.encode(["address"], [wallet.accounts[0].address]);

      // Creating the Deposit
      const spokePoolContract = new ethers.Contract(spokePoolAddress, spokePoolAbi, signer);

      const tx = await spokePoolContract.depositV3(
        wallet.accounts[0].address,                   // depositor
        handlerContract,                              // recipient
        inputToken,                                   // inputToken
        "0x0000000000000000000000000000000000000000", // outputToken
        BigNumber.from(totalRelayFee),                // inputAmount
        BigNumber.from("0"),                          // outputAmount
        handlerContractChainId,                       // destinationChainId
        ethers.constants.AddressZero,                 // exclusiveRelayer
        Math.floor(Date.now() / 1000),                // quoteTimestamp - Current timestamp
        Math.floor(Date.now() / 1000) + 21600,        // fillDeadline - 6 hours from now
        0,                                            // exclusivityDeadline
        encodedMessage,                               // message
        {
          gasLimit: ethers.utils.hexlify(1000000),
          value: BigNumber.from(totalRelayFee)
        }
      );
      await tx.wait();

      alert("Transaction successful!");
    } catch (error) {
      console.error(error);
      alert("Transaction failed. Please check the console for more details.");
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <h2>Across+ Message</h2>
      <p>Current chainId {originChainId}</p>
      <button onClick={handleDeposit} className='rounded-md bg-gray-900 border-none text-lg font-semibold cursor-pointer text-white px-3.5 py-3 mt-10'>
        {loading ? 'Processing...' : 'Send Message'}
      </button>
    </div>
  );
};

export default AcrossPlusComponent;

Adding the Component to the Page

After adding the component the page.tsx file should look like this:

'use client'

import AcrossPlusComponent from '@/components/across-plus';
import { useConnectWallet } from '@web3-onboard/react'
import { ethers } from 'ethers'

export default function Home() {
  const [{ wallet, connecting }, connect, disconnect] = useConnectWallet();

  let ethersProvider;

  if (wallet) {
    ethersProvider = new ethers.providers.Web3Provider(wallet.provider, 'any');
  }
  
  return (
    <main className="min-h-screen py-16 flex flex-col justify-center items-center flex-1">
      <h1 className="m-0 leading-tight text-4xl">
        Crosschain Executor{' '}
      </h1>

      <button
        className='rounded-md bg-gray-900 border-none text-lg font-semibold cursor-pointer text-white px-3.5 py-3 mt-10'
        disabled={connecting}
        onClick={() => (wallet ? disconnect(wallet) : connect())}
      >
        {connecting ? 'Connecting' : wallet ? 'Disconnect' : 'Connect'}
      </button>
      <div className='mt-10'>
        {wallet === null ? null : <AcrossPlusComponent />}
      </div>
    </main>
  )
}

Conclusion

With this setup, you should be able to connect your wallet, get a fee quote, and send a message across chains using the Across+ service. This guide ensures a modular approach, making it easier to manage and update the components as needed.

You can select the chain from where you will create the transaction on the bottom right and selecting the network we configured on the web3-onboard.ts file.

After sending the message and signing the transactions, you will successfully use Across+ to interact with the contract we created.

If you want to verify the transactions execution, you can vi the events on the blockchain explorer. This allows you to track and confirm all activities, ensuring transparency and accuracy in your cross-chain interactions.

You can find all the code used in this GitHub repository.

Summary

In this article, we've explored the challenges of blockchain interoperability and how Across+ addresses these through its intents-based architecture. By allowing seamless cross-chain transactions, Across+ facilitates the transfer of assets and data across different blockchain networks, improving liquidity and user experience.

I hope article serves as a resource for developers looking to enhance blockchain interoperability in their projects, providing practical insights and hands-on examples to facilitate seamless cross-chain interactions using Across+.

Thank you for reading! If you have any questions or need further assistance, feel free to reach out. You can connect with me on X / Twitter or LinkedIn. Looking forward to connect with you!

Subscribe to Saúl García
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.