---
title: "Bundles"
url: "/docs/build-on-signet/advanced/bundles/index.md"
description: "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.

## TypeScript

This page assumes you've [set up your clients](/docs/build-on-signet/getting-started/index.md) .
## 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](/docs/build-on-signet/advanced/simulating-bundles/index.md) .

## Rust

This page assumes you've completed the [getting started](/docs/build-on-signet/getting-started/index.md) 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, } ``` Key fields in EthSendBundle include:
txs: Ordered array of [EIP-2718 encoded](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-2718.md) transactions block_number: Target block for execution reverting_tx_hashes: Transaction hashes allowed to revert (see [Revertibility](#revertibility) ) The full struct also includes min_timestamp, max_timestamp, replacement_uuid, and others. See the sections below and the [alloy docs](https://docs.rs/alloy/latest/alloy/rpc/types/mev/struct.EthSendBundle.html) 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?; ``` > **Simulate first:** Before submitting, simulate your bundle to verify execution. See [Simulating Bundles](/docs/build-on-signet/advanced/simulating-bundles/index.md). ## 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](https://github.com/init4tech/signet-sol) repo includes example contracts (src/l2/examples/) that demonstrate common bundle patterns. Each contract creates an [order](/docs/build-on-signet/transfers/exit-signet/index.md) 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](https://github.com/init4tech/signet-sol/blob/main/src/l2/examples/GetOut.sol) 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](https://github.com/init4tech/signet-sol/blob/main/src/l2/examples/PayMe.sol) 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](https://github.com/init4tech/signet-sol/blob/main/src/l2/examples/PayYou.sol) 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](https://github.com/init4tech/signet-sol/blob/main/src/l2/examples/Flash.sol) 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.

## Terminal

This page assumes you've completed the [getting started](/docs/build-on-signet/getting-started/index.md) 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 Field Type Description txs string[] EIP-2718 encoded signed transactions (Signet side) blockNumber string Hex-encoded target block number hostTxs string[] EIP-2718 encoded signed transactions (Ethereum side) revertingTxHashes string[] Tx hashes allowed to revert without failing the bundle minTimestamp number Optional lower bound (unix seconds) maxTimestamp number Optional upper bound (unix seconds) replacementUuid string Optional 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](/docs/build-on-signet/advanced/simulating-bundles/index.md) for details.

