Skip to main content

Composable dApps

This page provides a high level overview on the flow of a Compose Network transaction and the various components used to achieve this.

The example that is used contains a cross-chain ERC20 token transfer, sending a token on one chain, receiving it on another, with the transaction being submitted synchronously and atomically to the L1.

End-to-End flow

  1. Create clients, Kernel accounts, and ECDSA validators on source and destination chains.
  2. Verify and, if needed, set ERC-20 allowance to the Bridge on the source chain.
  3. Prepare and sign UserOperations for both chains.
  4. Build per-chain raw transactions using compose_buildSignedUserOpsTx.
  5. Submit the atomic X-Transaction and obtain per-chain transaction hashes.

1) Start by creating clients, Kernel accounts, and validators

  • Create public clients for both chains using the ZeroDev SDK.
  • Initialize the validator configuration and corresponding Kernel accounts on source and destination.
import { createPublicClient, http } from "viem"
import { getEntryPoint, KERNEL_V3_1 } from "@zerodev/sdk/constants"
import { createKernelAccount } from "@zerodev/sdk"
import { toMultiChainECDSAValidator } from "@zerodev/multi-chain-ecdsa-validator"

const providerA = createPublicClient({ chain: chainA, transport: http(rpcA) })
const providerB = createPublicClient({ chain: chainB, transport: http(rpcB) })

const entryPoint = getEntryPoint("0.7")

const validatorA = await toMultiChainECDSAValidator(providerA as any, {
entryPoint, signer, kernelVersion: KERNEL_V3_1, multiChainIds: [chainA.id, chainB.id],
validatorAddress: addresses[chainA.id].MULTICHAIN_VALIDATOR,
})
const validatorB = await toMultiChainECDSAValidator(providerB as any, {
entryPoint, signer, kernelVersion: KERNEL_V3_1, multiChainIds: [chainA.id, chainB.id],
validatorAddress: addresses[chainB.id].MULTICHAIN_VALIDATOR,
})

const accountA = await createKernelAccount(providerA, {
entryPoint, plugins: { sudo: validatorA }, kernelVersion: KERNEL_V3_1,
accountImplementationAddress: addresses[chainA.id].KERNEL_IMPL,
factoryAddress: addresses[chainA.id].KERNEL_FACTORY,
})
const accountB = await createKernelAccount(providerB, {
entryPoint, plugins: { sudo: validatorB }, kernelVersion: KERNEL_V3_1,
accountImplementationAddress: addresses[chainB.id].KERNEL_IMPL,
factoryAddress: addresses[chainB.id].KERNEL_FACTORY,
})

2) Check and set token allowance (source chain)

  • Ensure the token allowance from the owner to BridgeA is greater than or equal to the intended amount.
  • If insufficient, submit an ERC-20 approve to BridgeA for the required amount.
const ERC20_ABI = [
"function allowance(address owner, address spender) view returns (uint256)",
"function approve(address spender, uint256 value) returns (bool)",
]

const allowance = await providerA.readContract({
address: token, abi: ERC20_ABI, functionName: "allowance", args: [ownerEOA, bridgeA],
}) as bigint

if (allowance < amount) {
await wallet.writeContract({
address: token, abi: ERC20_ABI, functionName: "approve", args: [bridgeA, amount],
})
}

3) Prepare and sign UserOperations

  • Encode Bridge.send(...) on the source chain and Bridge.receiveTokens(...) on the destination chain.
  • Build preview UserOperations (including gas caps and fees) and sign both with the Multi-Chain ECDSA validator.

Transactions involved:

These contract calls are wrapped as UserOperations (one per chain), then prepareAndSignUserOperations is called to sign transactions on both chains with just one action.

import { encodeFunctionData, parseAbi } from "viem"
import { prepareAndSignUserOperations } from "@zerodev/multi-chain-ecdsa-validator"

const bridgeAbi = parseAbi([
"function send(uint256,uint256,address,address,address,uint256,uint256)",
"function receiveTokens(uint256,uint256,address,address,uint256) returns (address,uint256)",
])

const sessionId = BigInt(Date.now())

const dataA = encodeFunctionData({
abi: bridgeAbi, functionName: "send",
args: [BigInt(chainA.id), BigInt(chainB.id), token, ownerEOA, receiver, amount, sessionId],
})

const dataB = encodeFunctionData({
abi: bridgeAbi, functionName: "receiveTokens",
args: [BigInt(chainA.id), BigInt(chainB.id), ownerEOA, receiver, sessionId],
})

const feesA = await providerA.estimateFeesPerGas()
const feesB = await providerB.estimateFeesPerGas()

const argsForA = {
account: accountA, chainId: chainA.id,
calls: [{ to: bridgeA, value: 0n, data: dataA }],
callGasLimit: 300000n, verificationGasLimit: 1200000n, preVerificationGas: 80000n,
maxFeePerGas: feesA.maxFeePerGas!, maxPriorityFeePerGas: feesA.maxPriorityFeePerGas!,
}
const argsForB = {
account: accountB, chainId: chainB.id,
calls: [{ to: bridgeB, value: 0n, data: dataB }],
callGasLimit: 300000n, verificationGasLimit: 1200000n, preVerificationGas: 80000n,
maxFeePerGas: feesB.maxFeePerGas!, maxPriorityFeePerGas: feesB.maxPriorityFeePerGas!,
}

const [signedA, signedB] = await prepareAndSignUserOperations(
[providerA as any, providerB as any],
[argsForA as any, argsForB as any],
)

4) Build per-chain raw transactions

  • Call compose_buildSignedUserOpsTx on each chain to obtain { raw, hash, gas } for the respective UserOperation.
    • raw: The raw transaction data that can be submitted to the blockchain
    • hash: The transaction hash that will be generated when the transaction is submitted
    • gas: The estimated gas consumption for the transaction
// Minimal canonicalization (convert numeric fields to hex, ensure initCode/paymaster defaults)
const toRpcUserOpCanonical = (op: any) => ({
sender: op.sender,
nonce: typeof op.nonce === "string" ? op.nonce : `0x${BigInt(op.nonce).toString(16)}`,
initCode: op.initCode ?? "0x",
callData: op.callData,
callGasLimit: `0x${BigInt(op.callGasLimit).toString(16)}`,
verificationGasLimit: `0x${BigInt(op.verificationGasLimit).toString(16)}`,
preVerificationGas: `0x${BigInt(op.preVerificationGas).toString(16)}`,
maxFeePerGas: `0x${BigInt(op.maxFeePerGas).toString(16)}`,
maxPriorityFeePerGas: `0x${BigInt(op.maxPriorityFeePerGas).toString(16)}`,
paymasterAndData: op.paymasterAndData ?? "0x",
signature: op.signature,
})

const uopA = toRpcUserOpCanonical(signedA)
const uopB = toRpcUserOpCanonical(signedB)

const buildA = await providerA.request({
method: "compose_buildSignedUserOpsTx",
params: [[uopA], { chainId: chainA.id }],
}) // => { raw, hash, gas, userOpHashes }

const buildB = await providerB.request({
method: "compose_buildSignedUserOpsTx",
params: [[uopB], { chainId: chainB.id }],
})

5) Send the X-Transaction

The raw transaction data from both chains is submitted to the Compose Sequencer, which processes transactions from both chains atomically. Either both transactions succeed or neither does, ensuring cross-chain atomicity.

  1. Bundle the two raw transaction entries and call eth_sendXTransaction to submit the atomic cross-chain bundle.
  2. Returns [txHashA, txHashB] corresponding to the submitted per-chain transactions.
import { encodeXtMessage } from "..."

const xtPayload = encodeXtMessage({
senderId: "client",
entries: [
{ chainId: chainA.id, rawTx: buildA.raw as `0x${string}` },
{ chainId: chainB.id, rawTx: buildB.raw as `0x${string}` },
],
})

const [txHashA, txHashB] = await providerA.request({
method: "eth_sendXTransaction",
params: [xtPayload],
})

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

Next Steps

This overview covers the essential flow for cross-chain transactions using Compose Network. For a minimized implementation using local private keys instead of Account Abstraction, see the Reading/Writing Tutorial.