Skip to main content

Bundles

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

Bundles are ordered sets of transactions that execute atomically. All succeed or all revert. They can span both Signet and Ethereum, enabling cross-chain operations like order fills where both sides must settle in the same block.

This page assumes you’ve set up your clients.

Build a bundle

SignetEthBundleBuilder constructs a bundle with a fluent API. At minimum, a bundle needs transactions and a target block number:

typescript
import { SignetEthBundleBuilder } from '@signet-sh/sdk/signing';

const bundle = SignetEthBundleBuilder.new()
  .withTxs([signedTx1, signedTx2])
  .withBlockNumber(targetBlock)
  .build();

signedTx1 and signedTx2 are raw signed transaction hex strings. Use walletClient.signTransaction to produce them:

typescript
const signedTx1 = await signetWalletClient.signTransaction({
  to: contractAddress,
  data: encodedCalldata,
  value: 0n,
});

The builder validates that at least one transaction and a block number are present when you call .build().

Cross-chain bundles

To include Ethereum transactions alongside Signet transactions, use withHostTxs. The builder includes both in the same block proposal:

typescript
const bundle = SignetEthBundleBuilder.new()
  .withTxs([signetTx])
  .withHostTxs([ethereumTx])
  .withBlockNumber(targetBlock)
  .build();

Revertible transactions

By default, if any transaction in the bundle reverts, the entire bundle is dropped. Mark specific transactions as allowed to revert with withRevertingTxHashes:

typescript
import { keccak256 } from 'viem';

const bundle = SignetEthBundleBuilder.new()
  .withTxs([requiredTx, optionalTx])
  .withRevertingTxHashes([keccak256(optionalTx)])
  .withBlockNumber(targetBlock)
  .build();

The required transaction must succeed. The optional transaction can revert without killing the bundle.

Replacing a bundle

To replace a pending bundle, set a replacement UUID. Submitting a new bundle with the same UUID replaces the previous one:

typescript
const bundle = SignetEthBundleBuilder.new()
  .withTxs([updatedTx])
  .withBlockNumber(targetBlock)
  .withReplacementUuid('my-stable-id')
  .build();

Submit to the tx-cache

Bundles go to the same transaction cache service as orders, on the /bundles endpoint:

typescript
import { createTxCacheClient } from '@signet-sh/sdk/client';
import { PARMIGIANA } from '@signet-sh/sdk/constants';

const txCache = createTxCacheClient(PARMIGIANA.txCacheUrl);
const { id } = await txCache.submitBundle(bundle);

submitBundle serializes the bundle and POSTs it to {txCacheUrl}/bundles. Builders poll this endpoint to discover bundles and include them in block proposals.

Block targeting

Bundles target a specific block number. If the bundle isn’t included by that block, it expires. You can also set a time window with withMinTimestamp and withMaxTimestamp:

typescript
const bundle = SignetEthBundleBuilder.new()
  .withTxs([signedTx])
  .withBlockNumber(targetBlock)
  .withMinTimestamp(Math.floor(Date.now() / 1000))
  .withMaxTimestamp(Math.floor(Date.now() / 1000) + 120) // 2 minute window
  .build();

To target the next block, query the current block number and add one:

typescript
const currentBlock = await signetPublicClient.getBlockNumber();
const targetBlock = currentBlock + 1n;

Next steps

For the protocol-level details on bundle structure, see the Rust variant of this page. For simulating bundles before submission, see Simulating Bundles.

This page assumes you’ve completed the getting started setup.

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.

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_000_000_000_000u128); // 1 USD on Signet (18 decimals)
let filler_amount = U256::from(995_000u64); // ~$0.995 USDC on Ethereum (6 decimals)

// 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(getOutCall {}.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(transferCall { to: user, amount: filler_amount }.abi_encode())
            .with_chain_id(3151908), // Parmigiana host chain
    )
    .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_000_000_000_000u128); // 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(gatedActionCall {}.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(tippedActionCall {}.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_000_000_000_000u128); // 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(flashActionCall {}.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.

This page assumes you’ve completed the getting started setup.

Submit a bundle

POST to the tx-cache /bundles endpoint:

bash
curl -X POST $TX_CACHE/bundles \
  -H "Content-Type: application/json" \
  -d '{
    "txs": ["0x...signed_tx_1", "0x...signed_tx_2"],
    "blockNumber": "0x15ba3",
    "revertingTxHashes": [],
    "hostTxs": []
  }'

Payload structure

FieldTypeDescription
txsstring[]EIP-2718 encoded signed transactions (Signet side)
blockNumberstringHex-encoded target block number
hostTxsstring[]EIP-2718 encoded signed transactions (Ethereum side)
revertingTxHashesstring[]Tx hashes allowed to revert without failing the bundle
minTimestampnumberOptional lower bound (unix seconds)
maxTimestampnumberOptional upper bound (unix seconds)
replacementUuidstringOptional UUID for replacing a pending bundle

Cross-chain bundles

Include Ethereum transactions in hostTxs for atomic cross-chain execution. Both sides execute in the same block or neither does:

bash
curl -X POST $TX_CACHE/bundles \
  -H "Content-Type: application/json" \
  -d '{
    "txs": ["0x...signet_tx"],
    "blockNumber": "0x15ba3",
    "hostTxs": ["0x...ethereum_tx"],
    "revertingTxHashes": []
  }'

Get the current block number

To target the next block:

bash
BLOCK=$(cast block-number --rpc-url $SIGNET_RPC)
NEXT_BLOCK=$(printf "0x%x" $(( BLOCK + 1 )))

Simulate before submitting

Use signet_callBundle via the RPC to simulate a bundle before submission:

bash
cast rpc signet_callBundle \
  "{\"txs\":[\"0x...signed_tx\"],\"blockNumber\":\"$NEXT_BLOCK\"}" \
  --rpc-url $SIGNET_RPC

See Simulating Bundles for details.

ESC

Start typing to search documentation...

Navigate Select ⌘K Open