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
- Create clients, Kernel accounts, and ECDSA validators on source and destination chains.
- Verify and, if needed, set ERC-20 allowance to the Bridge on the source chain.
- Prepare and sign UserOperations for both chains.
- Build per-chain raw transactions using
compose_buildSignedUserOpsTx. - 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
BridgeAis greater than or equal to the intended amount. - If insufficient, submit an ERC-20
approvetoBridgeAfor 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 andBridge.receiveTokens(...)on the destination chain. - Build preview UserOperations (including gas caps and fees) and sign both with the Multi-Chain ECDSA validator.
Transactions involved:
- Source chain: Bridge.send(...) to lock/burn and initiate transfer.
- Destination chain: Bridge.receiveTokens(...) to mint/release funds.
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_buildSignedUserOpsTxon each chain to obtain{ raw, hash, gas }for the respective UserOperation.raw: The raw transaction data that can be submitted to the blockchainhash: The transaction hash that will be generated when the transaction is submittedgas: 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.
- Bundle the two raw transaction entries and call
eth_sendXTransactionto submit the atomic cross-chain bundle. - 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.