Simple Cross-chain Transaction Tutorial
Overview
This tutorial demonstrates the concept of cross-chain transaction batching by first showing an approach using private key signing to get raw transaction data and transaction hashes, then explaining how to use cross-chain transaction batching with eth_sendXTransaction.
We want to execute two transactions across different chains atomically:
- Transaction 1: Call
ping()on the PingPong contract on Rollup A - Transaction 2: Call
pong()on the PingPong contract on Rollup B
Step 1: Private Key Signing
Start by creating two separate transactions and signing them with a private key:
import { createWalletClient, http, encodeFunctionData } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
// Setup
const privateKey = '0x...' // Your private key
const account = privateKeyToAccount(privateKey)
// Rollup A client
const clientA = createWalletClient({
account,
transport: http('https://rpc-a.testnet.compose.network')
})
// Rollup B client
const clientB = createWalletClient({
account,
transport: http('https://rpc-b.testnet.compose.network')
})
// PingPong contract addresses
const pingPongContractA = '0x...' // PingPong contract on Rollup A
const pingPongContractB = '0x...' // PingPong contract on Rollup B
// Transaction 1: Call ping() on Rollup A
const buildA = await clientA.prepareTransactionRequest({
to: pingPongContractA,
data: encodeFunctionData({
abi: pingPongABI,
functionName: 'ping',
args: [
chainB.id, // otherChain
pingPongContractB, // pongSender
account.address, // pingReceiver
1, // sessionId
'0x' // data
]
})
})
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 pong() on Rollup B
const buildB = await clientB.prepareTransactionRequest({
to: pingPongContractB,
data: encodeFunctionData({
abi: pingPongABI,
functionName: 'pong',
args: [
chainA.id, // otherChain
pingPongContractA, // pingSender
1, // sessionId
'0x' // data
]
})
})
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('Transaction A hash:', txHashA)
console.log('Transaction B hash:', txHashB)
// (optional) wait for both receipts
await Promise.all([
providerA.waitForTransactionReceipt({ hash: txHashA }),
providerB.waitForTransactionReceipt({ hash: txHashB }),
])