Transaction Bundles

Learn about how transaction bundles work.

What are transaction bundles?

Transaction bundles are a group of transactions that can be sent from one ZKsync chain (the origin chain) to another ZKsync chain (the destination chain) to be executed on the destination chain. The transactions in the bundle are executed one after another in the order they are sent.

They can be configured so that only a specific address can execute the bundle, or any address can. Bundles are not executed automatically, but this feature will be enabled in a future upgrade.

Can any contract be called?

To call a contract with a transaction bundle, the contract must support the receiveMessage interface.

    function receiveMessage(
        bytes32,        // message id
        bytes calldata, // ERC-7930 sender
        bytes calldata payload // bundle payload
    ) external payable returns (bytes4) {

If a contract does not have a receiveMessage function, you must use an adapter contract as an intermediary.

You can see an example of an adapter contract for a counter contract below:

How can I send a transaction bundle?

The basic steps of using bundles are:

  1. Construct a bundle.
  2. Send the bundle.
  3. Execute the bundle on the destination chain.

The InteropCenter contract is used to send bundles.

The InteropHandler contract is used to finalize and execute bundles.

For a step-by-step guide, check out the transaction bundle guide.

Is there a fee?

There is a small additional fee required to send a transaction bundle from one chain to another. Read the interop fees section to learn more about how fees work.

Lifecycle of a bundle

  1. Bundle creation: The sender builds an InteropBundle with a destination chain, one or more calls, and bundle attributes such as the execution address, unbundler address, and fee mode.
  2. Bundle submission: The sender calls InteropCenter.sendBundle on the source chain. The contract validates the destination chain, processes the call starters, collects the required fees, and emits the bundle as an L2 to L1 message.
  3. Interop root update: After the source chain settles through Gateway, the bundle message is included in the shared interop root for that chain.
  4. Root importation: The destination chain imports the relevant interop root, making the bundle message provable there.
  5. Proof submission: A relayer submits the ABI-encoded bundle and its MessageInclusionProof to InteropHandler on the destination chain.
  6. Bundle verification: InteropHandler checks that the proof matches the source chain, destination chain, and bundle contents, then marks the bundle as Verified.
  7. Bundle execution: InteropHandler.executeBundle calls each destination contract's receiveMessage function in order. If every call succeeds, the bundle is executed atomically and marked as FullyExecuted.
  8. Optional unbundling: If the bundle is verified but cannot be fully executed as one atomic operation, the authorized unbundler can use unbundleBundle to execute or cancel calls individually.

sendBundle Details

The interface for sending a bundle via the InteropCenter contract looks like this:

  function sendBundle(
        bytes calldata _destinationChainId,
        InteropCallStarter[] calldata _callStarters,
        bytes[] calldata _bundleAttributes
    ) external payable returns (bytes32 bundleHash);

The function returns the hash of the sent bundle.

There are three input arguments:

  • _destinationChainId: An ERC-7930 address that MUST have an empty address field, and encodes an EVM destination chain ID to send the bundle to.
  • _callStarters: An array of InteropCallStarter structs.
  • _bundleAttributes: A bytes array for the bundle's attributes.

Interop Call Starters

An interop call starter represents a transaction to be included in the bundle.

An interop call starter struct looks like this:

struct InteropCallStarter {
    bytes to;
    bytes data;
    bytes[] callAttributes;
}

It contains three fields:

  • to: the ERC-7930 address to call on the destination chain. The address must have empty ChainReference. This is due to the fact that chain ID is always provided on a bundle level as the destination chain ID.
  • data: The calldata payload to send to the to address on the destination chain.
  • callAttributes: The EIP-7786 attributes. Attributes are structured metadata to be used by the gateway.

Call Attributes

The call attributes describe how a call inside a bundle should be executed on the destination chain.

struct CallAttributes {
    uint256 interopCallValue;
    bool indirectCall;
    uint256 indirectCallMessageValue;
}

A CallAttributes struct includes:

  • interopCallValue: The base token value on destination chain to send for an interop call.
  • indirectCall: If the call is direct or not. An indirect call first calls a contract specified by the call starter, which then returns an actual call starter that is used to form an interop call. In particular, this is used for interop token transfers. In contrast, a direct call uses the call starter to form an interop call.
  • indirectCallMessageValue: The base token value on the origin chain to send for an indirect call.

Bundle Attributes

The information in the bundle attributes decides three things:

  1. If anyone can execute the bundle on the destination chain, or only some specific address.
  2. What address can unbundle the bundle.
  3. What fee mode is used when the bundle is sent.

A BundleAttributes struct looks like this:

struct BundleAttributes {
    bytes executionAddress;
    bytes unbundlerAddress;
    bool useFixedFee;
}
  • The executionAddress can either be empty or an ERC-7930 address. If the byte array is empty then execution is permissionless.
  • The unbundlerAddress must be an ERC-7930 address. If the unbundler is not set for a bundle, the InteropCenter contract sets the unbundler to be equal to the original sender.
  • useFixedFee selects the fee mode for the bundle. If true, the sender pays the fixed interop fee in ZK tokens. If false, the sender pays the dynamic per-call fee in the source chain's base token. If the attribute is omitted, it defaults to false.

executeBundle Details

Bundles are executed atomically, which means that if any call in the bundle fails, every call in the bundle is reverted.

function executeBundle(bytes memory _bundle, MessageInclusionProof memory _proof) external;

There are two input arguments for executeBundle:

  1. _bundle: the ABI-encoded InteropBundle to execute. This must match exactly the bundle that is sent in sendBundle.
  2. _proof: The inclusion proof for the bundle message. The bundle message itself gets broadcasted by the InteropCenter contract when the bundle was sent.

Getting the status of a bundle

Once a bundle is sent, its status can be queried from the InteropHandler contract using the bundle hash.

function bundleStatus(bytes32 bundleHash) external view returns (BundleStatus);

A bundle can have four different statuses:

enum BundleStatus {
    Unreceived,
    Verified,
    FullyExecuted,
    Unbundled
}
  • Unreceived: The bundle is not processed in any way yet.
  • Verified: The bundle's inclusion proof was accepted, but the bundle is not processed or executed.
  • FullyExecuted: All calls in the bundle have been executed via executeBundle.
  • Unbundled: Bundle was processed, but not executed. This can happen if a bundle is attempted to be executed but one of the calls fails.

Call status

You can also track the individual status of a call inside a bundle using the bundle hash and index of the call in the bundle.

function callStatus(bytes32 bundleHash, uint256 callIndex) external view returns (CallStatus);
enum CallStatus {
    Unprocessed,
    Executed,
    Cancelled
}

A call can either be:

  • Unprocessed: The call is not processed yet.
  • Executed: The call was successfully executed.
  • Cancelled: The call was cancelled during unbundling.

When a call fails

What happens depends on which path is used:

  • executeBundle is atomic. If any call fails, the entire transaction reverts and none of the calls are marked as executed.
  • verifyBundle only checks that the bundle message was included. It does not execute any calls, so it can be used first when you want to inspect or manage a bundle before execution.
  • unbundleBundle is the recovery path for non-atomic handling. After a bundle has been verified, the authorized unbundler can choose call-by-call statuses and process the bundle incrementally.

This means you cannot partially succeed through executeBundle, but you can still handle a problematic bundle after verification by using unbundleBundle to:

  • execute the calls that are valid
  • cancel the calls that should not run
  • leave other calls untouched until a later unbundling step

In practice, this is the flow to use when one call in a bundle is malformed or expected to fail, but you still want to recover and process the rest of the bundle in a controlled way.


Made with ❤️ by the ZKsync Community