Skip to main content

Cross-Chain Token Bridging Tutorial

Overview

This tutorial demonstrates cross-chain token bridging using the Compose Bridge contract with private key signing and eth_sendXTransaction for atomic cross-chain transfers.

We want to execute two transactions across different chains atomically:

  • Transaction 1: Call send() on the Bridge contract on Rollup A to burn/lock tokens
  • Transaction 2: Call receiveTokens() on the Bridge contract on Rollup B to mint/release tokens

Bridgeable ERC-20 Token Requirements

important

For an ERC-20 token to be bridgeable on the Compose Network, it must meet the following requirements:

  • Mint/Burn Functionality: The token contract must have mint and burn functions enabled. These functions are defined within the ERC-20 contract itself.
  • Bridge-Only Access: Only the Bridge contract can call the mint and burn functions. The token contract should restrict access to these functions to ensure only the authorized bridge contract can execute them.
  • Same Address on Both Chains: The token must have the same contract address on both the source and destination chains for the bridge to function correctly.

If you're using a custom ERC-20 token, ensure these requirements are implemented in your token contract. For testnet usage, you can reference the Example Bridgeable ERC20 Token that is already deployed and configured correctly.

Step 1: Private Key Signing

Start by creating two separate transactions and signing them with a private key:

import { createWalletClient, http, encodeFunctionData, parseEther } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'

// Setup
const privateKey = '0x...' // Your private key
const account = privateKeyToAccount(privateKey)

// Rollup A client (source chain)
const clientA = createWalletClient({
account,
transport: http('https://rpc-a.testnet.compose.network')
})

// Rollup B client (destination chain)
const clientB = createWalletClient({
account,
transport: http('https://rpc-b.testnet.compose.network')
})

// Bridge contract addresses
const bridgeContractA = '0x...' // Bridge contract on Rollup A
const bridgeContractB = '0x...' // Bridge contract on Rollup B

// Token and recipient details
const tokenAddress = '0x...' // ERC20 token address
const recipient = '0x...' // Recipient address
const amount = '100' // Amount to bridge
const sessionId = BigInt(Date.now()) // Unique session ID

// Bridge contract ABI
const bridgeABI = [
"function send(uint256,uint256,address,address,address,uint256,uint256)",
"function receiveTokens(uint256,uint256,address,address,uint256) returns (address,uint256)"
] as const

// Transaction 1: Call send() on Rollup A to burn/lock tokens
const buildA = await clientA.prepareTransactionRequest({
to: bridgeContractA,
data: encodeFunctionData({
abi: bridgeABI,
functionName: 'send',
args: [
BigInt(chainA.id), // sourceChainId
BigInt(chainB.id), // destChainId
tokenAddress, // token
account.address, // sender
recipient, // receiver
parseEther(amount), // amount
sessionId // sessionId
]
})
})

const txA = {
to: buildA.to,
data: buildA.data,
nonce: buildA.nonce,
gasPrice: buildA.gasPrice,
gas: buildA.gas,
value: buildA.value,
chainId: buildA.chainId
}

const signedTxA = await clientA.signTransaction(txA)

// Transaction 2: Call receiveTokens() on Rollup B to mint/release tokens
const buildB = await clientB.prepareTransactionRequest({
to: bridgeContractB,
data: encodeFunctionData({
abi: bridgeABI,
functionName: 'receiveTokens',
args: [
BigInt(chainA.id), // sourceChainId
BigInt(chainB.id), // destChainId
account.address, // sender
recipient, // receiver
sessionId // sessionId
]
})
})

const txB = {
to: buildB.to,
data: buildB.data,
nonce: buildB.nonce,
gasPrice: buildB.gasPrice,
gas: buildB.gas,
value: buildB.value,
chainId: buildB.chainId
}

const signedTxB = await clientB.signTransaction(txB)

Step 2: Encode the Cross-Chain Message

Encode the cross-chain message with our prepared transactions:

import { hexToBytes, type Hex } from "viem"
import protobuf from "protobufjs/light"

// Minimal protobuf schema for the messages we need
const rootJson = {
nested: {
rollup: {
nested: {
v1: {
nested: {
TransactionRequest: {
fields: {
chain_id: { type: "bytes", id: 1 },
transaction: { rule: "repeated", type: "bytes", id: 2 },
},
},
XTRequest: {
fields: {
transactions: { rule: "repeated", type: "TransactionRequest", id: 1 },
},
},
Message: {
fields: {
sender_id: { type: "string", id: 1 },
xt_request: { type: "XTRequest", id: 2 },
},
oneofs: {
payload: { oneof: ["xt_request"] },
},
},
},
},
},
},
},
} as const

const root = protobuf.Root.fromJSON(rootJson as any)
const Message = root.lookupType("rollup.v1.Message")
const XTRequest = root.lookupType("rollup.v1.XTRequest")
const TransactionRequest = root.lookupType("rollup.v1.TransactionRequest")

function encodeXtMessage(params: {
senderId?: string
entries: Array<{ chainId: number | bigint; rawTx: Hex }>
}): Hex {
const txs = params.entries.map(({ chainId, rawTx }) => {
const chainBytes = toBigEndianBytes(chainId)
const txBytes = hexToBytes(rawTx)
return TransactionRequest.create({ chain_id: chainBytes, transaction: [txBytes] })
})
const xt = XTRequest.create({ transactions: txs })
const msg = Message.create({ sender_id: params.senderId ?? "client", xt_request: xt })
const bytes = Message.encode(msg).finish()
return ("0x" + bytesToHex(bytes)) as Hex
}

function toBigEndianBytes(id: number | bigint): Uint8Array {
let v = BigInt(id)
if (v === 0n) return new Uint8Array([0])
const out: number[] = []
while (v > 0n) {
out.push(Number(v & 0xffn))
v >>= 8n
}
out.reverse()
return Uint8Array.from(out)
}

function bytesToHex(u8: Uint8Array): string {
const hex: string[] = new Array(u8.length)
for (let i = 0; i < u8.length; i++) {
const h = u8[i].toString(16).padStart(2, "0")
hex[i] = h
}
return hex.join("")
}

// Encode the cross-chain message with our signed transactions
const xtPayload = encodeXtMessage({
senderId: "client",
entries: [
{ chainId: chainA.id, rawTx: signedTxA },
{ chainId: chainB.id, rawTx: signedTxB },
],
})

Step 3: Submit the Cross-Chain Transaction

Submit the encoded message to the sequencer using the custom eth_sendXTransaction RPC:

// Send the cross-chain transaction
const [txHashA, txHashB] = await providerA.request({
method: "eth_sendXTransaction",
params: [xtPayload],
})

console.log('Bridge send transaction hash:', txHashA)
console.log('Bridge receive transaction hash:', txHashB)

// (optional) wait for both receipts
await Promise.all([
providerA.waitForTransactionReceipt({ hash: txHashA }),
providerB.waitForTransactionReceipt({ hash: txHashB }),
])