Skip to main content

Documentation/Build on Signet/Bundles

Bundles on Signet

How to create and submit transaction bundles that execute atomically across Ethereum and Signet.

Signet supports Flashbots-style transaction bundles—ordered sets of transactions that execute atomically—with additional functionality for cross-chain operations.

Bundles provide an atomic primitive for composing multiple actions:

  • Set up multiple legs of a trade
  • Perform multi-call transactions across Ethereum and Signet
  • Execute trades across various assets in the same block

All with guaranteed atomic execution in the specified order.

Use Cases

Orders express user intent — users sign, fillers execute. Bundles are for when you are composing and executing the transactions yourself: multi-step operations, specific ordering requirements, or acting as a filler. The host_txs field is where these meet — you can bundle a Signet order fill and the corresponding Ethereum fill transaction atomically.

Setup

Add the required Signet crates to your project:

bash
cargo add signet-bundle signet-tx-cache --git https://github.com/init4tech/signet-sdk
cargo add alloy --features primitives

Bundle Structure

A SignetEthBundle wraps a standard Flashbots bundle with an additional field for host chain transactions:

rust
use alloy::primitives::Bytes;
use alloy::rpc::types::mev::EthSendBundle;

pub struct SignetEthBundle {
    /// Standard Flashbots bundle structure
    pub bundle: EthSendBundle,

    /// Host Ethereum transactions to include atomically
    pub host_txs: Vec<Bytes>,
}

Key fields in EthSendBundle include:

  • txs: Ordered array of EIP-2718 encoded transactions
  • block_number: Target block for execution
  • reverting_tx_hashes: Transaction hashes allowed to revert (see Revertibility)

The full struct also includes min_timestamp, max_timestamp, replacement_uuid, and others — see the sections below and the alloy docs for the complete definition.

Creating a Bundle

rust
use alloy::primitives::Bytes;
use alloy::rpc::types::mev::EthSendBundle;
use signet_bundle::SignetEthBundle;

let bundle = SignetEthBundle {
    bundle: EthSendBundle {
        txs: vec![
            Bytes::from(encoded_tx_1),
            Bytes::from(encoded_tx_2),
        ],
        block_number: target_block,
        reverting_tx_hashes: vec![], // No transactions allowed to revert
        ..Default::default()
    },
    host_txs: vec![], // No host transactions
};

Submitting a Bundle

Use the TxCache client to submit bundles to the transaction cache:

rust
use signet_tx_cache::TxCache;

let tx_cache = TxCache::parmigiana();

let response = tx_cache.forward_bundle(bundle).await?;

Host Transactions

The host_txs field enables cross-chain atomic execution. You can bundle Signet transactions with Ethereum host transactions and guarantee all-or-nothing execution of the entire set.

rust
let bundle = SignetEthBundle {
    bundle: EthSendBundle {
        txs: vec![Bytes::from(signet_tx)],
        block_number: target_block,
        reverting_tx_hashes: vec![],
        ..Default::default()
    },
    // Include Ethereum transactions that must execute atomically
    host_txs: vec![
        Bytes::from(ethereum_tx_1),
        Bytes::from(ethereum_tx_2),
    ],
};

Host transactions are EIP-2718 encoded and execute on Ethereum in the same block as your Signet transactions.

Revertibility

By default, all transactions in a bundle must succeed. If any transaction fails simulation, the entire bundle is rejected.

To allow specific transactions to revert without failing the bundle, add their hashes to reverting_tx_hashes:

rust
use alloy::primitives::keccak256;

// encoded_tx must be the full signed EIP-2718 encoded transaction —
// the same bytes you put in the `txs` array. Wrong bytes here
// produce a silent hash mismatch and the bundle gets rejected.
let tx_hash = keccak256(&encoded_tx);

let bundle = SignetEthBundle {
    bundle: EthSendBundle {
        txs: vec![Bytes::from(encoded_tx)],
        block_number: target_block,
        reverting_tx_hashes: vec![tx_hash], // This transaction may revert
        ..Default::default()
    },
    host_txs: vec![],
};

Bundle Replacement

When you submit a bundle, forward_bundle returns a BundleResponse containing a UUID. Use update_bundle with that ID to replace the bundle’s contents:

rust
// Initial submission returns a bundle ID
let response = tx_cache.forward_bundle(bundle).await?;

// Build the replacement bundle
let updated_bundle = SignetEthBundle {
    bundle: EthSendBundle {
        txs: vec![Bytes::from(new_encoded_tx)],
        block_number: new_target_block,
        ..Default::default()
    },
    host_txs: vec![],
};

// Replace via PUT /bundles/{id}
tx_cache.update_bundle(&response.id.to_string(), updated_bundle).await?;

update_bundle issues a PUT request with the ID in the URL path.

Block Targeting

Bundles execute only at their specified block_number. If the target block passes without inclusion, the bundle becomes invalid.

rust
use alloy::providers::{Provider, ProviderBuilder};

let tx_cache = TxCache::parmigiana();
let provider = ProviderBuilder::new()
    .on_http("https://rpc.parmigiana.signet.sh".parse()?);

let current_block = provider.get_block_number().await?;

// Target the next block
let bundle = SignetEthBundle {
    bundle: EthSendBundle {
        txs: vec![Bytes::from(encoded_tx)],
        block_number: current_block + 1,
        ..Default::default()
    },
    host_txs: vec![],
};

let response = tx_cache.forward_bundle(bundle).await?;

Expiration

Bundles will stay in the cache until their max_timestamp is met or exceeded, but note that the block number must also be valid at the time of execution. If no max_timestamp is set, the cache defaults to a 10 minute Time To Live for bundles (~50 blocks).

rust
// Target the next block
let bundle = SignetEthBundle {
    bundle: EthSendBundle {
        txs: vec![Bytes::from(encoded_tx)],
        block_number: current_block + 1,
        max_timestamp: Some(expiration_timestamp),
        ..Default::default()
    },
    host_txs: vec![],
};

Examples

The signet-sol repo includes example contracts (src/l2/examples/) that demonstrate common bundle patterns. Each contract creates an order with a different combination of inputs and outputs — the examples below show how a searcher or filler would construct the corresponding bundle.

All examples assume the following setup:

rust
use alloy::{
    network::{EthereumWallet, TransactionBuilder},
    primitives::{address, Address, Bytes, U256},
    providers::{Provider, ProviderBuilder},
    rpc::types::{mev::EthSendBundle, TransactionRequest},
    signers::local::PrivateKeySigner,
    sol,
};
use signet_bundle::SignetEthBundle;

let signer: PrivateKeySigner = /* your signer */;
let wallet = EthereumWallet::from(signer.clone());

let signet_provider = ProviderBuilder::new()
    .wallet(wallet.clone())
    .on_http("https://rpc.parmigiana.signet.sh".parse()?);

let host_provider = ProviderBuilder::new()
    .wallet(wallet)
    .on_http("https://eth.llamarpc.com".parse()?);

let target_block = signet_provider.get_block_number().await? + 1;

GetOut — Cross-chain bridge exit

GetOut converts native USD on Signet to USDC on Ethereum. A user calls getOut() with some value; the contract takes a 0.5% fee and emits an order with a native USD input and a host USDC output. The filler fulfills by bundling the user’s Signet call with a USDC transfer on Ethereum.

rust
sol! {
    function getOut() external payable;
    function transfer(address to, uint256 amount) external returns (bool);
}

let get_out_address: Address = /* deployed GetOut contract */;
let user: Address = /* the user exiting */;
let host_usdc: Address = /* USDC on Ethereum */;

let amount = U256::from(1_000_000); // 1 USD (6 decimals)
let filler_amount = amount * U256::from(995) / U256::from(1000);

// Signet: call getOut() with the user's value
let signet_tx = signet_provider
    .fill(
        TransactionRequest::default()
            .with_to(get_out_address)
            .with_value(amount)
            .with_input(getOut::new(()).abi_encode())
            .with_chain_id(88888),
    )
    .await?
    .as_builder()
    .build(&signer)
    .await?;

// Ethereum: transfer USDC to the user
let host_tx = host_provider
    .fill(
        TransactionRequest::default()
            .with_to(host_usdc)
            .with_input(transfer::new((user, filler_amount)).abi_encode())
            .with_chain_id(1),
    )
    .await?
    .as_builder()
    .build(&signer)
    .await?;

let bundle = SignetEthBundle {
    bundle: EthSendBundle {
        txs: vec![signet_tx.encoded_2718().into()],
        block_number: target_block,
        ..Default::default()
    },
    // The host USDC transfer executes atomically with the Signet call
    host_txs: vec![host_tx.encoded_2718().into()],
};

This is the canonical cross-chain bundle pattern: txs carries the Signet leg, host_txs carries the Ethereum leg.

PayMe — Payment-gated function

PayMe provides a payMe(amount) modifier that gates a function behind payment. The contract emits an order with no input and an output demanding native asset to itself. The searcher fulfills by including a payment transaction alongside the function call.

rust
sol! {
    function gatedAction() external;
}

let contract: Address = /* contract using the payMe modifier */;
let payment_amount = U256::from(500_000); // 0.50 USD

// Call the gated function — this emits the order demanding payment
let call_tx = signet_provider
    .fill(
        TransactionRequest::default()
            .with_to(contract)
            .with_input(gatedAction::new(()).abi_encode())
            .with_chain_id(88888),
    )
    .await?
    .as_builder()
    .build(&signer)
    .await?;

// Pay the contract the demanded amount
let payment_tx = signet_provider
    .fill(
        TransactionRequest::default()
            .with_to(contract)
            .with_value(payment_amount)
            .with_chain_id(88888),
    )
    .await?
    .as_builder()
    .build(&signer)
    .await?;

let bundle = SignetEthBundle {
    bundle: EthSendBundle {
        // Both transactions on Signet, order matters:
        // 1. Call the function (emits the payment demand)
        // 2. Pay the contract
        txs: vec![
            call_tx.encoded_2718().into(),
            payment_tx.encoded_2718().into(),
        ],
        block_number: target_block,
        ..Default::default()
    },
    host_txs: vec![],
};

Both legs are on Signet so everything goes in txs. The function call comes first (creating the order), followed by the payment that fills it.

PayYou — Searcher incentive

PayYou is the inverse of PayMe. The paysYou(tip) modifier makes the contract send value as extractable MEV — an order with an input and no output. The searcher calls the tipped function and captures the payment.

rust
sol! {
    function tippedAction() external;
}

let contract: Address = /* contract using the paysYou modifier */;

// Call the tipped function — this emits an order with value to extract
let call_tx = signet_provider
    .fill(
        TransactionRequest::default()
            .with_to(contract)
            .with_input(tippedAction::new(()).abi_encode())
            .with_chain_id(88888),
    )
    .await?
    .as_builder()
    .build(&signer)
    .await?;

// Capture the payment (e.g., claim from the ORDERS contract)
let capture_tx = signet_provider
    .fill(
        TransactionRequest::default()
            .with_to(address!("000000000000007369676E65742D6f7264657273"))
            .with_value(U256::ZERO)
            .with_chain_id(88888),
    )
    .await?
    .as_builder()
    .build(&signer)
    .await?;

let bundle = SignetEthBundle {
    bundle: EthSendBundle {
        txs: vec![
            call_tx.encoded_2718().into(),
            capture_tx.encoded_2718().into(),
        ],
        block_number: target_block,
        ..Default::default()
    },
    host_txs: vec![],
};

The contract provides the incentive; the searcher profits by being the one to trigger it and capture the output.

Flash — Flash liquidity

Flash borrows an asset for the duration of a single function call. The modifier emits an order with both an output (asset received before execution) and an input (asset returned after). The filler provides the liquidity on either side.

rust
sol! {
    function flashAction() external;
}

let contract: Address = /* contract using the flash modifier */;
let flash_amount = U256::from(10_000_000); // 10 USD

// 1. Provide the asset to the contract before execution
let provide_tx = signet_provider
    .fill(
        TransactionRequest::default()
            .with_to(contract)
            .with_value(flash_amount)
            .with_chain_id(88888),
    )
    .await?
    .as_builder()
    .build(&signer)
    .await?;

// 2. Call the flash-gated function
let call_tx = signet_provider
    .fill(
        TransactionRequest::default()
            .with_to(contract)
            .with_input(flashAction::new(()).abi_encode())
            .with_chain_id(88888),
    )
    .await?
    .as_builder()
    .build(&signer)
    .await?;

// 3. Reclaim the asset after execution
let reclaim_tx = signet_provider
    .fill(
        TransactionRequest::default()
            .with_to(contract)
            .with_value(U256::ZERO)
            .with_chain_id(88888),
    )
    .await?
    .as_builder()
    .build(&signer)
    .await?;

let bundle = SignetEthBundle {
    bundle: EthSendBundle {
        // Order is critical: provide → execute → reclaim
        txs: vec![
            provide_tx.encoded_2718().into(),
            call_tx.encoded_2718().into(),
            reclaim_tx.encoded_2718().into(),
        ],
        block_number: target_block,
        ..Default::default()
    },
    host_txs: vec![],
};

Transaction ordering is essential here. The contract expects the asset to be available when the function runs and expects it to be pulled back after. The bundle’s atomic execution guarantee makes this safe — if any step fails, the whole bundle reverts.

ESC

Start typing to search documentation...

Navigate Select ⌘K Open