Skip to main content

Getting Started

This guide demonstrates how to build a complete cross-chain bridge transaction using the Compose Network. We'll walk through the entire flow from project setup to executing a working bridge transaction across two rollups.

Prerequisites

  • Node.js 18+ installed
  • A wallet with some testnet ETH
  • Basic knowledge of TypeScript and React

1. Project Setup

First, let's create a new project and install all required dependencies.

Initialize the project

mkdir compose-bridge-tutorial
cd compose-bridge-tutorial
npm init -y

Install dependencies

npm install @zerodev/sdk @zerodev/ecdsa-validator @zerodev/multi-chain-ecdsa-validator
npm install viem
npm install typescript @types/node
npm install --save-dev ts-node

Create project structure

mkdir -p src/config src/lib
touch src/config/chains.ts
touch src/lib/bridge.ts
touch src/index.ts
touch tsconfig.json package.json

2. Configure the project

Create tsconfig.json

{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

Create src/config/chains.ts

import { defineChain } from 'viem'

export const rollupA = defineChain({
id: 77777, // Custom rollup A
name: 'Rollup A',
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
rpcUrls: {
default: {
http: ['http://57.129.73.156:31130/'],
},
},
blockExplorers: {
default: { name: 'Rollup A Explorer', url: 'http://57.129.73.156:31130/' },
},
})

export const rollupB = defineChain({
id: 88888, // Custom rollup B
name: 'Rollup B',
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
rpcUrls: {
default: {
http: ['http://57.129.73.144:31133/'],
},
},
blockExplorers: {
default: { name: 'Rollup B Explorer', url: 'http://57.129.73.144:31133/' },
},
})

3. Verify the chain is Compose Network enabled

Use the Compose SDK to check that all of the chains being worked on have the correct Compose Network setup.

function verifyNetwork() {
// Check that both rollups have the required Compose Network contracts
// - Mailbox contract for cross-chain messaging
// - Bridge contract for token transfers
// - ZeroDev infrastructure for account abstraction
}

4. Set up Account Abstraction with ZeroDev

ZeroDev provides Account Abstraction capabilities, enabling multi-chain transaction batching and gas sponsorship. This allows users to sign multiple transactions across different chains in a single action.

Create src/lib/bridge.ts

This file contains the bridge logic based on your original working example:

import { createKernelAccount, getEntryPoint, KERNEL_V3_1 } from "@zerodev/sdk"
import { toMultiChainECDSAValidator, prepareAndSignUserOperations } from "@zerodev/multi-chain-ecdsa-validator"
import { createPublicClient, http, encodeFunctionData, parseAbi, type Address } from "viem"
import { prepareUserOperation } from "viem/account-abstraction"
import { getAction } from "viem/utils"
import { rollupA, rollupB } from "../config/chains"

const ERC20_ABI = parseAbi([
"function decimals() view returns (uint8)",
"function allowance(address owner, address spender) view returns (uint256)",
"function approve(address spender, uint256 value) returns (bool)",
])

const BRIDGE_ABI = parseAbi([
"function send(uint256 chainSrc,uint256 chainDest,address token,address sender,address receiver,uint256 amount,uint256 sessionId)",
"function receiveTokens(uint256 chainSrc,uint256 chainDest,address sender,address receiver,uint256 sessionId) returns (address token,uint256 amount)",
])

// These would be your actual deployed contract addresses
const TOKEN_ADDRESS: Address = '0x...' // Your test token address
const BRIDGE_A_ADDRESS: Address = '0x...' // Bridge contract on rollup A
const BRIDGE_B_ADDRESS: Address = '0x...' // Bridge contract on rollup B
const VALIDATOR_A_ADDRESS: Address = '0x...' // Multi-chain validator on rollup A
const VALIDATOR_B_ADDRESS: Address = '0x...' // Multi-chain validator on rollup B
const KERNEL_IMPL_A: Address = '0x...' // Kernel implementation on rollup A
const KERNEL_IMPL_B: Address = '0x...' // Kernel implementation on rollup B
const KERNEL_FACTORY_A: Address = '0x...' // Kernel factory on rollup A
const KERNEL_FACTORY_B: Address = '0x...' // Kernel factory on rollup B

export async function executeBridgeTransaction(
wallet: any,
owner: Address,
amount: string
): Promise<{ txA: string; txB: string }> {
const entryPoint = getEntryPoint("0.7")
const clientA = createPublicClient({
chain: rollupA,
transport: http(rollupA.rpcUrls.default.http[0])
})
const clientB = createPublicClient({
chain: rollupB,
transport: http(rollupB.rpcUrls.default.http[0])
})

// Read decimals to scale amount
const decimals = await clientA.readContract({
address: TOKEN_ADDRESS,
abi: ERC20_ABI,
functionName: "decimals"
}) as number

const amountWei = toUnits(amount, decimals)

// Create validators and accounts
const validatorA = await toMultiChainECDSAValidator(clientA, {
entryPoint,
signer: wallet,
kernelVersion: KERNEL_V3_1,
validatorAddress: VALIDATOR_A_ADDRESS,
multiChainIds: [rollupA.id, rollupB.id],
})

const validatorB = await toMultiChainECDSAValidator(clientB, {
entryPoint,
signer: wallet,
kernelVersion: KERNEL_V3_1,
validatorAddress: VALIDATOR_B_ADDRESS,
multiChainIds: [rollupA.id, rollupB.id],
})

const accountA = await createKernelAccount(clientA, {
entryPoint,
plugins: { sudo: validatorA },
kernelVersion: KERNEL_V3_1,
accountImplementationAddress: KERNEL_IMPL_A,
factoryAddress: KERNEL_FACTORY_A,
useMetaFactory: false,
})

const accountB = await createKernelAccount(clientB, {
entryPoint,
plugins: { sudo: validatorB },
kernelVersion: KERNEL_V3_1,
accountImplementationAddress: KERNEL_IMPL_B,
factoryAddress: KERNEL_FACTORY_B,
useMetaFactory: false,
})

// Check allowance on Rollup A only (spender = Bridge A)
const allowance = await clientA.readContract({
address: TOKEN_ADDRESS,
abi: ERC20_ABI,
functionName: "allowance",
args: [owner, BRIDGE_A_ADDRESS]
}) as bigint

const needsApproval = allowance < amountWei ? amountWei - allowance : 0n

if (needsApproval > 0n) {
// Approve the bridge contract to spend tokens
await wallet.writeContract({
account: wallet.account,
chain: rollupA,
address: TOKEN_ADDRESS,
abi: ERC20_ABI,
functionName: "approve",
args: [BRIDGE_A_ADDRESS, amountWei],
})
}

// Get gas estimates
const feesA = await clientA.estimateFeesPerGas()
const feesB = await clientB.estimateFeesPerGas()

// Prepare bridge calls
const dataA = encodeFunctionData({
abi: BRIDGE_ABI,
functionName: "send",
args: [
BigInt(rollupA.id),
BigInt(rollupB.id),
TOKEN_ADDRESS,
owner,
owner,
amountWei,
BigInt(12345)
],
})

const dataB = encodeFunctionData({
abi: BRIDGE_ABI,
functionName: "receiveTokens",
args: [
BigInt(rollupA.id),
BigInt(rollupB.id),
owner,
owner,
BigInt(12345)
],
})

const toPreviewArgs = (account: any, chainId: number, fees: any, data: `0x${string}`, to: Address): any => ({
account,
chainId,
calls: [{ to, value: 0n, data }],
callGasLimit: 300000n,
verificationGasLimit: 1200000n,
preVerificationGas: 80000n,
maxFeePerGas: fees.maxFeePerGas!,
maxPriorityFeePerGas: fees.maxPriorityFeePerGas!,
})

const previewA = await getAction(clientA, prepareUserOperation, "prepareUserOperation")(
toPreviewArgs(accountA, rollupA.id, feesA, dataA, BRIDGE_A_ADDRESS)
)
const previewB = await getAction(clientB, prepareUserOperation, "prepareUserOperation")(
toPreviewArgs(accountB, rollupB.id, feesB, dataB, BRIDGE_B_ADDRESS)
)

// Sign both UserOperations together
const [signedA, signedB] = await prepareAndSignUserOperations(
[clientA, clientB],
[
toPreviewArgs(accountA, rollupA.id, feesA, dataA, BRIDGE_A_ADDRESS),
toPreviewArgs(accountB, rollupB.id, feesB, dataB, BRIDGE_B_ADDRESS),
]
)

// Build raw transactions
const [buildA, buildB] = await Promise.all([
clientA.request({
method: "compose_buildSignedUserOpsTx",
params: [[signedA], { chainId: rollupA.id }]
}),
clientB.request({
method: "compose_buildSignedUserOpsTx",
params: [[signedB], { chainId: rollupB.id }]
})
])

// Encode cross-chain message
const payload = encodeXtMessage({
senderId: "client",
entries: [
{ chainId: rollupA.id, rawTx: buildA.raw as `0x${string}` },
{ chainId: rollupB.id, rawTx: buildB.raw as `0x${string}` },
],
})

// Send cross-chain transaction
const result = await clientA.request({
method: "eth_sendXTransaction",
params: [payload]
})

if (Array.isArray(result) && result.length >= 2) {
return { txA: result[0], txB: result[1] }
}

throw new Error("Failed to execute bridge transaction")
}

function toUnits(value: string, decimals: number): bigint {
const [integer, fraction = ""] = (value || "0").split(".")
const paddedFraction = (fraction + "0".repeat(decimals)).slice(0, decimals)
const combined = (integer || "0") + (paddedFraction ? paddedFraction : "")
return BigInt(combined || "0")
}

function encodeXtMessage(message: any): string {
// This would be your actual XT message encoding function
// For now, returning a placeholder
return JSON.stringify(message)
}

5. Create the main script

Create src/index.ts

import { createWalletClient, http, parseEther } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { executeBridgeTransaction } from './lib/bridge'
import { rollupA } from './config/chains'

async function main() {
// Replace with your private key (keep this secure!)
const privateKey = '0x...' // Your private key here
const account = privateKeyToAccount(privateKey as `0x${string}`)

// Create wallet client
const wallet = createWalletClient({
account,
chain: rollupA,
transport: http(rollupA.rpcUrls.default.http[0])
})

console.log('Starting bridge transaction...')
console.log('From:', account.address)
console.log('Amount: 100 tokens')

try {
const result = await executeBridgeTransaction(
wallet,
account.address,
'100' // Amount to bridge
)

console.log('✅ Bridge transaction successful!')
console.log('Rollup A transaction:', result.txA)
console.log('Rollup B transaction:', result.txB)
console.log('View on Rollup A:', `http://57.129.73.156:31130/tx/${result.txA}`)
console.log('View on Rollup B:', `http://57.129.73.144:31133/tx/${result.txB}`)
} catch (error) {
console.error('❌ Bridge transaction failed:', error)
}
}

main().catch(console.error)

Update package.json

{
"name": "compose-bridge-tutorial",
"version": "1.0.0",
"description": "Simple script to execute cross-chain bridge transactions",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts"
},
"dependencies": {
"@zerodev/sdk": "^4.0.0",
"@zerodev/ecdsa-validator": "^4.0.0",
"@zerodev/multi-chain-ecdsa-validator": "^4.0.0",
"viem": "^2.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.0.0",
"ts-node": "^10.0.0"
}
}

6. Configure your environment

Update contract addresses

Edit src/lib/bridge.ts and replace the placeholder addresses with your actual deployed contract addresses:

// Replace these with your actual deployed contract addresses
const TOKEN_ADDRESS: Address = '0x...' // Your test token address
const BRIDGE_A_ADDRESS: Address = '0x...' // Bridge contract on rollup A
const BRIDGE_B_ADDRESS: Address = '0x...' // Bridge contract on rollup B
const VALIDATOR_A_ADDRESS: Address = '0x...' // Multi-chain validator on rollup A
const VALIDATOR_B_ADDRESS: Address = '0x...' // Multi-chain validator on rollup B
const KERNEL_IMPL_A: Address = '0x...' // Kernel implementation on rollup A
const KERNEL_IMPL_B: Address = '0x...' // Kernel implementation on rollup B
const KERNEL_FACTORY_A: Address = '0x...' // Kernel factory on rollup A
const KERNEL_FACTORY_B: Address = '0x...' // Kernel factory on rollup B

Update your private key

Edit src/index.ts and replace the placeholder with your actual private key:

const privateKey = '0x...' // Your private key here

⚠️ Security Note: Never commit your private key to version control. Consider using environment variables in production.

7. Run the script

Build and run

npm run build
npm start

Or run directly with ts-node

npm run dev

What happens when you run it

The script will:

  1. Connect to your wallet using the private key
  2. Execute the bridge transaction automatically
  3. Display the results in the console with transaction hashes
  4. Show explorer links for both rollup transactions

8. What happens during execution

When you run the script, the following happens:

  1. Account Setup: Creates ZeroDev kernel accounts for both chains
  2. Token Approval: Checks and handles ERC20 token approvals if needed
  3. Transaction Preparation: Encodes bridge contract calls for both chains
  4. Multi-chain Signing: Signs both transactions together in one action
  5. Cross-chain Execution: Sends the transactions as a cross-chain message
  6. Confirmation: Waits for both transactions to be confirmed
  7. Results Display: Shows transaction hashes and explorer links in the console