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:
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:
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:
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:
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:
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:
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:
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:
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:
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.
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_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.
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.
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.
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:
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:
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:
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:
cast rpc signet_callBundle \
"{\"txs\":[\"0x...signed_tx\"],\"blockNumber\":\"$NEXT_BLOCK\"}" \
--rpc-url $SIGNET_RPCSee Simulating Bundles for details.