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.
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:
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.”
User Intent Submission: Users submit an intent specifying the desired outcome on a different blockchain.
Relayer Network: Competitive relayers bid to fulfill these intents, providing the best rates and fastest execution.
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.
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:
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.
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.
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.
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.
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 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:
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.
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.
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.
First, create a new Next.js project by running the following command:
npx create-next-app@latest crosschain-executor
cd crosschain-executor
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.
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'
},
],
}
})
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
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.
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!