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.

  1. Create a new project folder
    mkdir hardhat-example
    cd hardhat-example
    
  1. Initialize a new Hardhat 3 project with Node Test Runner and Viem.
    npx hardhat --init
    
  1. Install the zksync-js npm package:
    npm install -D @matterlabs/zksync-js
    
  2. Configure the hardhat.config.ts file 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'],
        },
      },
    
  3. Create a new file in the contracts folder called InteropCounter.sol:
    touch contracts/InteropCounter.sol
    
  4. Copy and paste the contract below into the InteropCounter.sol file.
    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));
            }
        }
    }
    
  5. Create a new script in the scripts folder called interop-counter.ts.
    touch scripts/interop-counter.ts
    
  1. Copy and paste the script below into the interop-counter.ts file.
    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);
    
  1. 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
  1. Create a new project folder
    mkdir hardhat-example
    cd hardhat-example
    
  1. Initialize a new Hardhat 3 project with Mocha and Ethers.js.
    npx hardhat --init
    
  1. Install the zksync-js npm package:
    npm install -D @matterlabs/zksync-js
    
  2. Configure the hardhat.config.ts file 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'],
        },
      },
    
  3. Create a new file in the contracts folder called InteropCounter.sol:
    touch contracts/InteropCounter.sol
    
  4. Copy and paste the contract below into the InteropCounter.sol file.
    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));
            }
        }
    }
    
  5. Create a new script in the scripts folder called interop-counter.ts.
    touch scripts/interop-counter.ts
    
  1. Copy and paste the script below into the interop-counter.ts file.
    import { createClient } from '@matterlabs/zksync-js/ethers';
    import { createEthersSdk } from '@matterlabs/zksync-js/ethers/sdk';
    import { AbiCoder } from 'ethers';
    import { network } from 'hardhat';
    
    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 [l1Signer] = await l1.ethers.getSigners();
    
    const client = createClient({
      l1: l1Signer.provider,
      l2: chain1.ethers.provider,
      signer: l1Signer,
    });
    const sdk = createEthersSdk(client, {
      interop: { gwChain: gatewayRPC },
    });
    
    const [chain2Signer] = await chain2.ethers.getSigners();
    const counterFactory = await chain2.ethers.getContractFactory('InteropCounter', chain2Signer);
    const counter = await counterFactory.deploy();
    await counter.waitForDeployment();
    
    const counterAddress = await counter.getAddress();
    console.log(`Counter deployed on localZKsyncOSChain2 at: ${counterAddress}`);
    
    const tx = await counter.inc();
    await tx.wait();
    const startingNumber = await counter.x();
    console.log('StartingNumber: ', startingNumber);
    
    // // use op=0 to call inc()
    // // or op=1 to call incBy(by)
    const data = AbiCoder.defaultAbiCoder().encode(['uint8'], [0]) as `0x${string}`;
    
    const params = {
      actions: [
        {
          type: 'call' as const,
          to: counterAddress,
          data: data,
        },
      ],
      // Optional bundle-level execution constraints:
      // execution: { only: someExecAddress },
      // unbundling: { by: someUnbundlerAddress },
    };
    
    const created = await sdk.interop.create(chain2.ethers.provider, params);
    console.log('✅ Created interop transaction.');
    
    const finalizationInfo = await sdk.interop.wait(chain2.ethers.provider, created);
    console.log('✅ Bundle is finalized on source; root available on destination.');
    
    const finalizationResult = await sdk.interop.finalize(chain2.ethers.provider, finalizationInfo);
    console.log('Finalize result:', finalizationResult);
    
    const finalNumber = await counter.x();
    console.log('FinalNumber: ', finalNumber);
    
  1. 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
  1. Create a new Foundry project.
    forge init Interop
    
    cd Interop
    
  2. Create a new file in the src folder called InteropCounter.sol.
    touch src/InteropCounter.sol
    
  3. Copy and paste the contract below into 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));
            }
        }
    }
    
  4. Build the project.
    forge build
    
  5. Create a new script in the script folder called InteropCounterDeployAndSendBundle.s.sol. We will use this script to deploy the contract and send a transaction bundle to increment the counter.
    touch script/InteropCounterDeployAndSendBundle.s.sol
    
  6. Copy and paste the script below into InteropCounterDeployAndSendBundle.s.sol.
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.28;
    
    import {InteropCounter} from "../src/InteropCounter.sol";
    
    interface VmDeployAndSend {
        function startBroadcast() external;
        function stopBroadcast() external;
        function createFork(string calldata urlOrAlias) external returns (uint256 forkId);
        function selectFork(uint256 forkId) external;
    }
    
    interface IInteropCenterMinimal {
        struct InteropCallStarter {
            bytes to;
            bytes data;
            bytes[] callAttributes;
        }
    
        function sendBundle(
            bytes calldata destinationChainId,
            InteropCallStarter[] calldata callStarters,
            bytes[] calldata bundleAttributes
        ) external payable returns (bytes32 bundleHash);
    
        function interopProtocolFee() external view returns (uint256);
    }
    
    contract InteropCounterDeployAndSendBundle {
        uint256 internal constant CHAIN2_ID = 6566;
    
        string internal constant CHAIN1_RPC_URL = "http://localhost:3050";
        string internal constant CHAIN2_RPC_URL = "http://localhost:3051";
    
        address internal constant INTEROP_CENTER_ADDRESS = 0x000000000000000000000000000000000001000d;
        IInteropCenterMinimal internal constant INTEROP_CENTER = IInteropCenterMinimal(INTEROP_CENTER_ADDRESS);
        VmDeployAndSend internal constant vm = VmDeployAndSend(address(uint160(uint256(keccak256("hevm cheat code")))));
    
        event CounterDeployed(address counter, uint256 startingNumber);
        event BundleSent(bytes32 bundleHash, address target, uint256 destinationChainId);
    
        function run() public returns (address counterAddress, bytes32 bundleHash) {
            uint256 chain2Fork = vm.createFork(CHAIN2_RPC_URL);
            vm.selectFork(chain2Fork);
    
            vm.startBroadcast();
            InteropCounter counter = new InteropCounter();
            counter.inc();
            vm.stopBroadcast();
    
            counterAddress = address(counter);
            emit CounterDeployed(counterAddress, counter.x());
    
            uint256 chain1Fork = vm.createFork(CHAIN1_RPC_URL);
            vm.selectFork(chain1Fork);
    
            vm.startBroadcast();
            bundleHash = _sendSingleActionBundle(CHAIN2_ID, counterAddress, abi.encode(uint8(0)));
            vm.stopBroadcast();
    
            emit BundleSent(bundleHash, counterAddress, CHAIN2_ID);
        }
    
        function _sendSingleActionBundle(uint256 destinationChainId, address target, bytes memory payload)
            internal
            returns (bytes32)
        {
            bytes memory dstChain = _formatEvmV1Chain(destinationChainId);
            uint256 fee = INTEROP_CENTER.interopProtocolFee();
    
            IInteropCenterMinimal.InteropCallStarter[] memory starters = new IInteropCenterMinimal.InteropCallStarter[](1);
            starters[0] = IInteropCenterMinimal.InteropCallStarter({
                to: _formatEvmV1AddressOnly(target),
                data: payload,
                callAttributes: new bytes[](0)
            });
    
            return INTEROP_CENTER.sendBundle{value: fee}(dstChain, starters, new bytes[](0));
        }
    
        function _formatEvmV1Chain(uint256 chainid) internal pure returns (bytes memory) {
            bytes memory chainRef = _toChainReference(chainid);
            return abi.encodePacked(bytes4(0x00010000), uint8(chainRef.length), chainRef, uint8(0));
        }
    
        function _formatEvmV1AddressOnly(address addr) internal pure returns (bytes memory) {
            return abi.encodePacked(bytes6(0x000100000014), addr);
        }
    
        function _toChainReference(uint256 chainid) internal pure returns (bytes memory out) {
            if (chainid == 0) {
                out = new bytes(1);
                out[0] = bytes1(uint8(0));
                return out;
            }
    
            uint256 tmp = chainid;
            uint256 len;
            while (tmp != 0) {
                unchecked {
                    ++len;
                }
                tmp >>= 8;
            }
    
            out = new bytes(len);
            for (uint256 i = len; i > 0; ) {
                out[i - 1] = bytes1(uint8(chainid));
                chainid >>= 8;
                unchecked {
                    --i;
                }
            }
        }
    }
    
  7. Create a new script in the script folder called InteropCounterFinalizeBundle.s.sol. We will use this script to finalize the bundle on the destination chain.
    touch script/InteropCounterFinalizeBundle.s.sol
    
  8. Copy and paste the script below into InteropCounterFinalizeBundle.s.sol.
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.28;
    
    import {InteropCounter} from "../src/InteropCounter.sol";
    
    interface IInteropHandlerExec {
        struct L2Message {
            uint16 txNumberInBatch;
            address sender;
            bytes data;
        }
    
        struct MessageInclusionProof {
            uint256 chainId;
            uint256 l1BatchNumber;
            uint256 l2MessageIndex;
            L2Message message;
            bytes32[] proof;
        }
    
        function executeBundle(bytes calldata bundle, MessageInclusionProof calldata proof) external;
    }
    
    interface VmFinalize {
        function startBroadcast() external;
        function stopBroadcast() external;
    }
    
    contract InteropCounterFinalizeBundle {
        address internal constant INTEROP_HANDLER_ADDRESS = 0x000000000000000000000000000000000001000E;
        IInteropHandlerExec internal constant INTEROP_HANDLER = IInteropHandlerExec(INTEROP_HANDLER_ADDRESS);
        VmFinalize internal constant vm = VmFinalize(address(uint160(uint256(keccak256("hevm cheat code")))));
    
        event BundleFinalized(bytes32 bundleHash, uint256 finalCounterValue);
    
        function run(address counterAddress, bytes calldata proofEncoded) public returns (uint256 finalCounterValue) {
            IInteropHandlerExec.MessageInclusionProof memory proof =
                abi.decode(proofEncoded, (IInteropHandlerExec.MessageInclusionProof));
            bytes memory bundleEncoded = _bundleFromMessageData(proof.message.data);
    
            vm.startBroadcast();
            INTEROP_HANDLER.executeBundle(bundleEncoded, proof);
            vm.stopBroadcast();
    
            finalCounterValue = InteropCounter(counterAddress).x();
            emit BundleFinalized(keccak256(bundleEncoded), finalCounterValue);
        }
    
        function _bundleFromMessageData(bytes memory messageData) internal pure returns (bytes memory) {
            require(messageData.length > 0, "missing message data");
            require(messageData[0] == bytes1(0x01), "unexpected bundle prefix");
    
            bytes memory out = new bytes(messageData.length - 1);
            for (uint256 i = 1; i < messageData.length; ++i) {
                out[i - 1] = messageData[i];
            }
            return out;
        }
    }
    
  9. Create a helper script called get-proof-encoded.sh. This script will be used to fetch the proof for the bundle, which is needed for finalization.
    touch script/get-proof-encoded.sh
    
  10. Copy and paste the script below into script/get-proof-encoded.sh.
    #!/usr/bin/env bash
    set -euo pipefail
    
    if ! command -v jq >/dev/null 2>&1; then
      echo "error: jq is required" >&2
      exit 1
    fi
    
    if ! command -v cast >/dev/null 2>&1; then
      echo "error: cast is required" >&2
      exit 1
    fi
    
    if [[ $# -gt 2 ]]; then
      echo "usage: $0 [send_tx_hash] [source_rpc_url]" >&2
      echo "example: $0 0xabc... http://localhost:3050" >&2
      echo "example: $0" >&2
      exit 1
    fi
    
    SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
    PROJECT_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
    DEFAULT_BROADCAST_RUN_JSON="$PROJECT_ROOT/broadcast/multi/InteropCounterDeployAndSendBundle.s.sol-latest/run.json"
    
    SEND_TX_HASH=""
    SOURCE_RPC="http://localhost:3050"
    MAX_ATTEMPTS="${MAX_ATTEMPTS:-30}"
    SLEEP_SECONDS="${SLEEP_SECONDS:-2}"
    DEBUG="${DEBUG:-0}"
    FOUNDRY_RUN_JSON="${FOUNDRY_RUN_JSON:-$DEFAULT_BROADCAST_RUN_JSON}"
    
    CHAIN_ID=6565
    INTEROP_CENTER="0x000000000000000000000000000000000001000d"
    L1_MESSENGER="0x0000000000000000000000000000000000008008"
    INTEROP_ROOT_STORAGE="0x0000000000000000000000000000000000010008"
    DEST_RPC="${DEST_RPC:-http://localhost:3051}"
    GW_RPC="${GW_RPC:-http://localhost:3052}"
    DEST_PRIVATE_KEY="${DEST_PRIVATE_KEY:-${LOCAL_PRIVATE_KEY:-}}"
    GW_PRIVATE_KEY="${GW_PRIVATE_KEY:-${LOCAL_PRIVATE_KEY:-}}"
    
    SEND_RECEIPT_JSON=$(mktemp)
    PROOF_JSON=$(mktemp)
    TX_DETAILS_JSON=$(mktemp)
    FINALIZED_BLOCK_JSON=$(mktemp)
    GATEWAY_CHAIN_JSON=$(mktemp)
    ROOT_CALL_JSON=$(mktemp)
    
    cleanup() {
      rm -f "$SEND_RECEIPT_JSON" "$PROOF_JSON" "$TX_DETAILS_JSON" "$FINALIZED_BLOCK_JSON" "$GATEWAY_CHAIN_JSON" "$ROOT_CALL_JSON"
    }
    
    trap cleanup EXIT
    
    receipt_filter='if .result != null then .result else . end'
    messenger_log_filter='if .result != null then .result else . end | .logs|to_entries[]|select((.value.address|ascii_downcase)==($a|ascii_downcase))|.key'
    message_data_filter='if .result != null then .result else . end | .logs[$i].data'
    proof_filter='if .result != null then .result else . end'
    root_filter='if .result != null then .result else . end'
    
    debug_log() {
      if [[ "$DEBUG" == "1" ]]; then
        echo "debug: $*" >&2
      fi
    }
    
    poke_chain() {
      local rpc_url="$1"
      local private_key="$2"
      local label="$3"
    
      if [[ -z "$private_key" ]]; then
        debug_log "poke_${label}_chain_skipped no_private_key"
        return 0
      fi
    
      local sender
      sender="$(cast wallet address --private-key "$private_key" 2>/dev/null || true)"
      if [[ -z "$sender" ]]; then
        debug_log "failed_to_derive_${label}_sender"
        return 0
      fi
    
      debug_log "poke_${label}_chain sender=$sender"
      local send_output
      local tx_hash
      local sender_balance
      sender_balance="$(cast balance "$sender" --rpc-url "$rpc_url" 2>/dev/null || true)"
      debug_log "poke_${label}_chain sender_balance_wei=${sender_balance:-unknown}"
      send_output="$(
        cast send \
          --json \
          --private-key "$private_key" \
          --rpc-url "$rpc_url" \
          "$sender" \
          --value 1wei \
          2>&1 || true
      )"
    
      tx_hash="$(printf '%s' "$send_output" | jq -r '.transactionHash // .hash // empty' 2>/dev/null || true)"
      if [[ -z "$tx_hash" ]]; then
        echo "error: failed to send ${label}-chain poke transaction on $rpc_url" >&2
        echo "hint: sender $sender balance on ${label} chain is ${sender_balance:-unknown} wei" >&2
        echo "$send_output" >&2
        exit 1
      fi
    
      debug_log "poke_${label}_chain tx_hash=$tx_hash"
    
      for _ in $(seq 1 20); do
        if cast receipt "$tx_hash" --rpc-url "$rpc_url" >/dev/null 2>&1; then
          return 0
        fi
        sleep 1
      done
    
      debug_log "poke_${label}_chain_receipt_timeout tx_hash=$tx_hash"
    }
    
    poke_gateway_chain() {
      poke_chain "$GW_RPC" "${GW_PRIVATE_KEY:-}" "gateway"
    }
    
    poke_destination_chain() {
      poke_chain "$DEST_RPC" "${DEST_PRIVATE_KEY:-}" "destination"
    }
    
    resolve_latest_send_tx_hash() {
      local interop_center_lower
      interop_center_lower="$(printf '%s' "$INTEROP_CENTER" | tr '[:upper:]' '[:lower:]')"
    
      if [[ ! -f "$FOUNDRY_RUN_JSON" ]]; then
        echo "error: could not find Foundry broadcast artifact at $FOUNDRY_RUN_JSON" >&2
        echo "hint: pass the send tx hash explicitly or set FOUNDRY_RUN_JSON" >&2
        exit 1
      fi
    
      local latest_hash
      latest_hash="$(
        jq -r \
          --arg interop_center "$interop_center_lower" \
          '
            [
              .deployments[]
              | .transactions[]
              | select(
                  .transactionType == "CALL"
                  and (.transaction.to // "" | ascii_downcase) == $interop_center
                  and (.function // "") == "sendBundle(bytes,(bytes,bytes,bytes[])[],bytes[])"
                )
              | .hash
            ]
            | last // empty
          ' \
          "$FOUNDRY_RUN_JSON"
      )"
    
      if [[ -z "$latest_hash" ]]; then
        echo "error: could not find a sendBundle transaction hash in $FOUNDRY_RUN_JSON" >&2
        echo "hint: rerun the deploy script or pass the send tx hash explicitly" >&2
        exit 1
      fi
    
      echo "$latest_hash"
    }
    
    if [[ $# -eq 1 ]]; then
      if [[ "$1" == 0x* ]]; then
        SEND_TX_HASH="$1"
      else
        SOURCE_RPC="$1"
      fi
    elif [[ $# -eq 2 ]]; then
      SEND_TX_HASH="$1"
      SOURCE_RPC="$2"
    fi
    
    if [[ -z "$SEND_TX_HASH" ]]; then
      SEND_TX_HASH="$(resolve_latest_send_tx_hash)"
      debug_log "resolved_latest_send_tx_hash=$SEND_TX_HASH from $FOUNDRY_RUN_JSON"
    fi
    
    for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do
      curl -s -X POST "$SOURCE_RPC" -H 'content-type: application/json' \
        --data "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getTransactionReceipt\",\"params\":[\"$SEND_TX_HASH\"]}" \
        > "$SEND_RECEIPT_JSON"
    
      if jq -e "$receipt_filter | .transactionHash != null" "$SEND_RECEIPT_JSON" >/dev/null; then
        break
      fi
    
      if [[ "$attempt" -eq "$MAX_ATTEMPTS" ]]; then
        echo "error: transaction receipt not available after $MAX_ATTEMPTS attempts" >&2
        exit 1
      fi
    
      sleep "$SLEEP_SECONDS"
    done
    
    debug_log "receipt json:"
    if [[ "$DEBUG" == "1" ]]; then
      cat "$SEND_RECEIPT_JSON" >&2
    fi
    debug_log "receipt transactionHash=$(jq -r "$receipt_filter | .transactionHash // \"null\"" "$SEND_RECEIPT_JSON")"
    debug_log "receipt logs length=$(jq -r "$receipt_filter | (.logs | length) // 0" "$SEND_RECEIPT_JSON")"
    if [[ "$DEBUG" == "1" ]]; then
      jq -r "$receipt_filter | .logs | to_entries[] | \"log[\(.key)] address=\(.value.address) topic0=\(.value.topics[0] // \"\")\"" "$SEND_RECEIPT_JSON" >&2 || true
    fi
    
    curl -s -X POST "$SOURCE_RPC" -H 'content-type: application/json' \
      --data "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"zks_getTransactionDetails\",\"params\":[\"$SEND_TX_HASH\"]}" \
      > "$TX_DETAILS_JSON" || true
    
    if [[ "$DEBUG" == "1" ]]; then
      echo "debug: tx details json:" >&2
      cat "$TX_DETAILS_JSON" >&2
    fi
    
    MESSENGER_LOG_INDEXES=()
    while IFS= read -r idx; do
      MESSENGER_LOG_INDEXES+=("$idx")
    done < <(
      jq -r \
        --arg a "$L1_MESSENGER" \
        "$messenger_log_filter" \
        "$SEND_RECEIPT_JSON"
    )
    
    if [[ "${#MESSENGER_LOG_INDEXES[@]}" -eq 0 ]]; then
      debug_log "expected messenger sender=$L1_MESSENGER"
      echo "error: no L1MessageSent log found in receipt" >&2
      exit 1
    fi
    
    LAST_MESSENGER_INDEX=$((${#MESSENGER_LOG_INDEXES[@]} - 1))
    CHOSEN_LOG_INDEX="${MESSENGER_LOG_INDEXES[$LAST_MESSENGER_INDEX]}"
    
    L2_TO_L1_LOG_INDEX=-1
    for i in "${!MESSENGER_LOG_INDEXES[@]}"; do
      if [[ "${MESSENGER_LOG_INDEXES[$i]}" -eq "$CHOSEN_LOG_INDEX" ]]; then
        L2_TO_L1_LOG_INDEX="$i"
        break
      fi
    done
    
    if [[ "$L2_TO_L1_LOG_INDEX" -lt 0 ]]; then
      echo "error: failed to compute L2-to-L1 log index" >&2
      exit 1
    fi
    
    L1_MESSAGE_DATA_ENCODED=$(jq -r --argjson i "$CHOSEN_LOG_INDEX" "$message_data_filter" "$SEND_RECEIPT_JSON")
    debug_log "chosen_messenger_log_index=$CHOSEN_LOG_INDEX l2_to_l1_log_index=$L2_TO_L1_LOG_INDEX"
    debug_log "l1_message_data_encoded=$L1_MESSAGE_DATA_ENCODED"
    
    L1_MESSAGE_DATA=$(cast decode-abi "x()(bytes)" "$L1_MESSAGE_DATA_ENCODED" | tr -d '()')
    debug_log "decoded_l1_message_data=$L1_MESSAGE_DATA"
    
    SOURCE_BLOCK_HEX=$(jq -r "$receipt_filter | .blockNumber" "$SEND_RECEIPT_JSON")
    SOURCE_BLOCK_DEC=$(cast --to-dec "$SOURCE_BLOCK_HEX")
    debug_log "source_block_hex=$SOURCE_BLOCK_HEX source_block_dec=$SOURCE_BLOCK_DEC"
    
    for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do
      curl -s -X POST "$SOURCE_RPC" -H 'content-type: application/json' \
        --data '{"jsonrpc":"2.0","id":1,"method":"eth_getBlockByNumber","params":["finalized",false]}' \
        > "$FINALIZED_BLOCK_JSON"
    
      FINALIZED_BLOCK_HEX=$(jq -r "$receipt_filter | .number // \"null\"" "$FINALIZED_BLOCK_JSON")
      if [[ "$FINALIZED_BLOCK_HEX" == "null" ]]; then
        FINALIZED_BLOCK_DEC=-1
      else
        FINALIZED_BLOCK_DEC=$(cast --to-dec "$FINALIZED_BLOCK_HEX")
      fi
      debug_log "finalized_block_hex=$FINALIZED_BLOCK_HEX finalized_block_dec=$FINALIZED_BLOCK_DEC"
    
      if [[ "$FINALIZED_BLOCK_DEC" -lt "$SOURCE_BLOCK_DEC" ]]; then
        if [[ "$attempt" -eq "$MAX_ATTEMPTS" ]]; then
          echo "error: source block not finalized after $MAX_ATTEMPTS attempts" >&2
          exit 1
        fi
        sleep "$SLEEP_SECONDS"
        continue
      fi
    
      curl -s -X POST "$SOURCE_RPC" -H 'content-type: application/json' \
        --data "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"zks_getL2ToL1LogProof\",\"params\":[\"$SEND_TX_HASH\",$L2_TO_L1_LOG_INDEX,\"messageRoot\"]}" \
        > "$PROOF_JSON"
    
      if jq -e "$proof_filter | .batchNumber != null and .id != null and (.proof | type == \"array\") and .gatewayBlockNumber != null" "$PROOF_JSON" >/dev/null; then
        break
      fi
    
      if [[ "$attempt" -eq "$MAX_ATTEMPTS" ]]; then
        echo "error: L2-to-L1 proof not available after $MAX_ATTEMPTS attempts" >&2
        exit 1
      fi
    
      sleep "$SLEEP_SECONDS"
    done
    
    debug_log "proof json:"
    if [[ "$DEBUG" == "1" ]]; then
      cat "$PROOF_JSON" >&2
    fi
    
    BATCH=$(jq -r "$proof_filter | .batchNumber" "$PROOF_JSON")
    MSG_ID=$(jq -r "$proof_filter | .id" "$PROOF_JSON")
    GW_BLOCK_NUMBER=$(jq -r "$proof_filter | .gatewayBlockNumber" "$PROOF_JSON")
    TX_INDEX_HEX=$(jq -r "$receipt_filter | .transactionIndex" "$SEND_RECEIPT_JSON")
    TX_INDEX=$(cast --to-dec "$TX_INDEX_HEX")
    PROOF_ARRAY=$(jq -r "$proof_filter | \"[\" + (.proof | join(\",\")) + \"]\"" "$PROOF_JSON")
    debug_log "batch=$BATCH msg_id=$MSG_ID gateway_block_number=$GW_BLOCK_NUMBER tx_index_hex=$TX_INDEX_HEX tx_index=$TX_INDEX"
    debug_log "proof_array=$PROOF_ARRAY"
    
    curl -s -X POST "$GW_RPC" -H 'content-type: application/json' \
      --data '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}' \
      > "$GATEWAY_CHAIN_JSON"
    
    GW_CHAIN_HEX=$(jq -r "$root_filter | . // \"null\"" "$GATEWAY_CHAIN_JSON")
    if [[ "$GW_CHAIN_HEX" == "null" ]]; then
      echo "error: failed to read gateway chain id from $GW_RPC" >&2
      exit 1
    fi
    GW_CHAIN_ID=$(cast --to-dec "$GW_CHAIN_HEX")
    debug_log "gateway_chain_hex=$GW_CHAIN_HEX gateway_chain_id=$GW_CHAIN_ID"
    
    ROOT_CALL_DATA=$(cast calldata "interopRoots(uint256,uint256)" "$GW_CHAIN_ID" "$GW_BLOCK_NUMBER")
    debug_log "root_call_data=$ROOT_CALL_DATA"
    
    for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do
      curl -s -X POST "$DEST_RPC" -H 'content-type: application/json' \
        --data "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_call\",\"params\":[{\"to\":\"$INTEROP_ROOT_STORAGE\",\"data\":\"$ROOT_CALL_DATA\"},\"latest\"]}" \
        > "$ROOT_CALL_JSON"
    
      ROOT_VALUE=$(jq -r "$root_filter | . // \"null\"" "$ROOT_CALL_JSON")
      debug_log "interop_root_attempt=$attempt root=$ROOT_VALUE"
    
      if [[ "$ROOT_VALUE" != "null" && "$ROOT_VALUE" != "0x0000000000000000000000000000000000000000000000000000000000000000" ]]; then
        break
      fi
    
      if [[ "$attempt" -eq "$MAX_ATTEMPTS" ]]; then
        echo "error: destination interop root not available after $MAX_ATTEMPTS attempts" >&2
        echo "hint: expected interopRoots($GW_CHAIN_ID, $GW_BLOCK_NUMBER) on $DEST_RPC" >&2
        exit 1
      fi
    
      poke_gateway_chain
      poke_destination_chain
      sleep "$SLEEP_SECONDS"
    done
    
    PROOF_ENCODED_HEX=$(cast abi-encode \
      "x((uint256,uint256,uint256,(uint16,address,bytes),bytes32[]))" \
      "($CHAIN_ID,$BATCH,$MSG_ID,($TX_INDEX,$INTEROP_CENTER,$L1_MESSAGE_DATA),$PROOF_ARRAY)")
    debug_log "proof_encoded_hex=$PROOF_ENCODED_HEX"
    
    echo "$PROOF_ENCODED_HEX"
    
  11. Make the helper script executable.
    chmod +x script/get-proof-encoded.sh
    
  12. Set your private key for deploying. The private key used below should already be pre-funded if you followed the full local setup guide.
    export LOCAL_PRIVATE_KEY="0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110"
    
  13. Run the deploy and send bundle script to deploy the contract on chain 6566 and send the bundle from chain 6565. Save the deployed contract address from the output.
    forge script script/InteropCounterDeployAndSendBundle.s.sol:InteropCounterDeployAndSendBundle \
      --rpc-url http://localhost:3050 \
      --broadcast \
      --skip-simulation \
      --gas-estimate-multiplier 1000 \
      --private-key $LOCAL_PRIVATE_KEY
    
  14. Generate the encoded proof. By default, the helper script reads the latest sendBundle transaction hash from Foundry's broadcast/multi/InteropCounterDeployAndSendBundle.s.sol-latest/run.json file.
    PROOF_ENCODED_HEX="$(./script/get-proof-encoded.sh)"
    

    If needed, you can pass the bundle send transaction hash explicitly. This hash is the last hash output from the deploy and send script, under ### 6565
    PROOF_ENCODED_HEX="$(./script/get-proof-encoded.sh <BUNDLE_SEND_TX_HASH> http://localhost:3050)"
    
  15. Finalize the bundle on chain 6566. Replace <DEPLOYED_CONTRACT_ADDRESS> with your deployed contract address.
    forge script script/InteropCounterFinalizeBundle.s.sol:InteropCounterFinalizeBundle \
      --sig "run(address,bytes)" <DEPLOYED_CONTRACT_ADDRESS> $PROOF_ENCODED_HEX \
      --rpc-url http://localhost:3051 \
      --broadcast \
      --skip-simulation \
      --gas-estimate-multiplier 1000 \
      --private-key $LOCAL_PRIVATE_KEY
    

You should see finalCounterValue: uint256 2 is output, confirming that the counter contract was incremented from the transaction bundle.


Made with ❤️ by the ZKsync Community