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:
cargo add signet-bundle signet-tx-cache --git https://github.com/init4tech/signet-sdk
cargo add alloy --features primitivesBundle Structure
A SignetEthBundle wraps a standard Flashbots bundle with an additional field for host chain transactions:
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 transactionsblock_number: Target block for executionreverting_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
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:
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.
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:
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:
// 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.
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).
// 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:
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.
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.
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.
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.
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.