Transaction Bundles
A guide for sending L2 -> L2 transaction bundles with ZKsync Connect.
Choose between using Hardhat 3 + viem, Hardhat 3 + ethers, or with a Foundry test.
- Create a new project folder
mkdir hardhat-example cd hardhat-example
- Initialize a new Hardhat 3 project with Node Test Runner and Viem.
npx hardhat --init
- Install the
zksync-jsnpm package:npm install -D @matterlabs/zksync-js - Configure the
hardhat.config.tsfile with the three local chains setup in the local setup and a rich wallet, and the set the ignition required confirmations to 1.ignition: { requiredConfirmations: 1, }, networks: { localZKsyncOSL1: { type: 'http', chainType: 'l1', url: 'http://localhost:8545', accounts: ['0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110'], }, localZKsyncOSChain1: { type: 'http', chainType: 'generic', url: 'http://localhost:3050', accounts: ['0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110'], }, localZKsyncOSChain2: { type: 'http', chainType: 'generic', url: 'http://localhost:3051', accounts: ['0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110'], }, }, - Create a new file in the
contractsfolder calledInteropCounter.sol:touch contracts/InteropCounter.sol - Copy and paste the contract below into the
InteropCounter.solfile.InteropCounter.sol// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.28; contract InteropCounter { uint256 public x; address constant INTEROP_HANDLER = 0x000000000000000000000000000000000001000E; event Increment(uint256 by); function inc() public { x++; emit Increment(1); } function incBy(uint256 by) public { require(by > 0, "incBy: increment should be positive"); x += by; emit Increment(by); } // payload format example: // abi.encode(uint8(0)) => inc() // abi.encode(uint8(1), uint256(5)) => incBy(5) function receiveMessage( bytes32, // message id bytes calldata, // ERC-7930 sender bytes calldata payload // bundle payload ) external payable returns (bytes4) { require(msg.sender == INTEROP_HANDLER, "only interop handler"); (uint8 op, uint256 by) = _decodePayload(payload); if (op == 0) { inc(); } else if (op == 1) { incBy(by); } else { revert("unknown op"); } return this.receiveMessage.selector; } function _decodePayload(bytes calldata payload) internal pure returns (uint8 op, uint256 by) { if (payload.length == 32) { op = abi.decode(payload, (uint8)); by = 0; } else { (op, by) = abi.decode(payload, (uint8, uint256)); } } } - Create a new script in the
scriptsfolder calledinterop-counter.ts.touch scripts/interop-counter.ts
- Copy and paste the script below into the
interop-counter.tsfile.import { createClient } from '@matterlabs/zksync-js/viem'; import { createViemSdk } from '@matterlabs/zksync-js/viem/sdk'; import { network } from 'hardhat'; import { encodeAbiParameters } from 'viem'; const chain1 = await network.create({ network: 'localZKsyncOSChain1', chainType: 'generic', }); const chain2 = await network.create({ network: 'localZKsyncOSChain2', chainType: 'generic', }); const l1 = await network.create({ network: 'localZKsyncOSL1', chainType: 'l1', }); const gatewayRPC = 'http://localhost:3052'; const l1PublicClient = await l1.viem.getPublicClient(); const chain1PublicClient = await chain1.viem.getPublicClient(); const chain2PublicClient = await chain2.viem.getPublicClient(); const [l1WalletClient] = await l1.viem.getWalletClients(); const client = createClient({ l1: l1PublicClient, l2: chain1PublicClient, l1Wallet: l1WalletClient, }); const sdk = createViemSdk(client, { interop: { gwChain: gatewayRPC }, }); const counter = await chain2.viem.deployContract('InteropCounter'); const counterAddress = counter.address; console.log(`Counter deployed on localZKsyncOSChain2 at: ${counterAddress}`); const txHash = await counter.write.inc(); await chain2PublicClient.waitForTransactionReceipt({ hash: txHash }); const startingNumber = await counter.read.x(); console.log('StartingNumber: ', startingNumber); // // use op=0 to call inc() // // or op=1 to call incBy(by) const data = encodeAbiParameters([{ type: 'uint8' }], [0]); const params = { actions: [ { type: 'call' as const, to: counterAddress, data, }, ], // Optional bundle-level execution constraints: // execution: { only: someExecAddress }, // unbundling: { by: someUnbundlerAddress }, }; const created = await sdk.interop.create(chain2PublicClient, params); console.log('✅ Created interop transaction.'); const finalizationInfo = await sdk.interop.wait(chain2PublicClient, created); console.log('✅ Bundle is finalized on source; root available on destination.'); const finalizationResult = await sdk.interop.finalize(chain2PublicClient, finalizationInfo); console.log('Finalize result:', finalizationResult); const finalNumber = await counter.read.x(); console.log('FinalNumber: ', finalNumber);
- Run the script
npx hardhat run scripts/interop-counter.ts
You should see the script output:
Counter deployed on localZKsyncOSChain2 at: 0x...
StartingNumber: 1n
✅ Created interop transaction.
✅ Bundle is finalized on source; root available on destination.
Finalize result: {
bundleHash: '0x...',
dstExecTxHash: '0x...'
}
FinalNumber: 2n