contender scenario config

June 5, 2026 · View on GitHub

a walkthrough for creating new Contender scenarios with basic templates.


To create new scenarios for Contender to run, make a new TOML file:

touch MyScenario.toml

env variables & placeholders

Contender scenario files make use of a templating engine that allows us to define variables in the file that can be used throughout the file. This can be used for contract deployments and custom variables.

To set a custom variable, create an [env] section at the top of the file:

[env]
<varName> = ""

For example, the UniV2 scenario defines an initialSupply variable to be used when minting tokens:

[env]
initialSupply = "00ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"

You may create as many variables as you want.

Following that declaration in the TOML file, you can reference the variable in several places using the {placeholder} syntax.

In the UniV2 example, the initialSupply variable is used as a constructor argument via signature and args (no manual hex surgery):

[[create]]
name = "testToken"

signature = "(uint256 initialSupply)"  # or "constructor(uint256 initialSupply)"
args = [
    "{initialSupply}",
]
bytecode = "0x608060..."

defining a contract deployment

Copy in the following boilerplate:

[[create]]
bytecode = ""
name = ""

This is how contender defines a contract deployment.

  • The bytecode field contains the data we need to deploy the contract to the chain.
  • The name field gives the contract a name that can be used in later steps, where we define transactions to send to our contracts.
  • The from_pool field generates a new signer that can be referenced by that name, so that we don’t have to hard-code a sender, allowing us to write scenarios that anyone can run.

Start by assigning the name of your contract to the name field.

To get bytecode for your contract using forge & jq:

# in your forge project directory
forge build
# replace "SpamMe" with your own contract
cat out/SpamMe.sol/SpamMe.json | jq .bytecode.object

Copy-and-paste the output of that last command into the bytecode field.

When you’re done, it should look something like this:

[[create]]
bytecode = "0x608060405..." # truncated; it's usually very long
name = "SpamMe2"

Add as many of these [[create]] steps as you need. They will be deployed in order.

passing constructor args

Provide constructor args by specifying the Solidity constructor type signature and the args list. Placeholders are supported in args and will be resolved at runtime. Contender will ABI-encode the args and append them to bytecode automatically.

For example, in the builtin UniV2 scenario, the router takes two addresses:

[[create]]
name = "uniRouterV2"

signature = "(address,address)"   # or "constructor(address,address)"
args = [
    "{uniV2Factory}",
    "{weth}",
]
bytecode = "0x60c06040..."

💡 Do not manually append or zero-pad values in bytecode. Constructor args are ABI-encoded and appended for you.

defining setup steps

Contender can run one-time transactions after deploying your contracts. This lets you set the base state for your scenario before spamming. In the UniV2 scenario, we use this to deposit ETH for WETH, mint tokens, launch trading pairs, etc.

Copy in this boilerplate definition for a [[setup]] step:

[[setup]]
kind = ""
to = ""

signature = ""
args = [
]
value = ""

You’ll notice some new fields:

  • kind just gives the transaction a label, which can make debugging easier
  • to is the recipient address.
    • You may use a {placeholder} here.
  • signature is a solidity function signature; the function called by this transaction.
  • args are the arguments to the function; they can be decimal strings or hex strings.
    • You may use a {placeholder} here.
  • value is how much ether to send with the transaction.
    • May be passed as a decimal string or hex string.
    • You may use a {placeholder} here.

Here’s a snippet from the UniV2 scenario, where we deposit ETH to get WETH, then create a token pair on UniV2:

# get 10 WETH
[[setup]]
kind = "admin_weth_deposit"
to = "{weth}"

signature = "function deposit() public payable"
value = "10000000000000000000"

# create TOKEN1/WETH pair
[[setup]]
kind = "univ2_create_pair_token1-weth"
to = "{uniV2Factory}"

signature = "function createPair(address tokenA, address tokenB) external returns (address pair)"
args = [
     "{weth}",
     "{testToken}"
]

Add as many setup steps as you need, then once you’re confident your scenario’s base state is constructed, you’re ready to define some spam steps.

defining spam steps

Spam steps have the same structure as setup steps, but they’re nested in a wrapper. Copy this template into your scenario file:

[[spam]]

[spam.tx]
to = ""
from_pool = ""
signature = ""
args = []

💡 Notice that we have a new [spam.tx] directive under [[spam]] . This allows us to differentiate between mempool txs and bundles (we’ll cover bundles later).

One important thing to consider when writing these is the from_pool definition. You probably don’t want to spam with the “admin” pool (though it’s your choice), so we advise you use a different from_pool name for your spam definitions.

Here’s an example from the builtin mempool scenario:

[[spam]]

[spam.tx]
to = "{SpamMe2}"
from_pool = "bluepool"
signature = "consumeGas(uint256 gasAmount)"
args = ["1350000"]

💡 You may want to define different from_pool definitions for different kinds of transactions to logically group your agents, which will make your orderflow easier to reason about.

optional gas limit & sending reverting txs

You have the option to set gas_limit to skip gas estimation. This also enables reverting transactions to be sent.

[[spam]]

[spam.tx]
to = "{SpamMe2}"
from_pool = "bluepool"
signature = "consumeGas(uint256 gasAmount)"
args = ["1350000"]
gas_limit = 1350000

access lists

Spam transactions can include EIP-2930 access-list entries. This is useful for workloads that already know which account and storage keys need to be warm, while still sending EIP-1559 transactions by default.

[[spam]]

[spam.tx]
to = "0x1111111111111111111111111111111111111111"
from_pool = "bluepool"
signature = "touch(bytes32 lookupKey)"
args = [
    "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
]
gas_limit = 200000

[[spam.tx.access_list]]
address = "0x1111111111111111111111111111111111111111"
storageKeys = [
    "0x0100000000000000000000000000000000000000000000000000000000000000",
    "0x0300000000000000000000000000000000000000000000000000000000000000",
]

sending bundles

The [spam.tx] directive sends a mempool transaction using eth_sendRawTransaction, but Contender also supports bundles.

To send a bundle, use [[spam.bundle.tx]] instead of [spam.tx]. The double-brackets indicate that we can specify it multiple times; each [[spam.bundle.tx]] under a single [[spam]] directive represents a transaction in a bundle.

The following snippet specifies a bundle with two transactions. The first one consumes some gas, then the second one pays a tip directly to the block builder to get included faster:

# spam bundle
[[spam]]

[[spam.bundle.tx]]
to = "{SpamMe}"
from_pool = "bluepool"
signature = "consumeGas(uint256 gasAmount)"
args = ["51000"]
fuzz = [{ param = "gasAmount", min = "22000", max = "69000" }]

[[spam.bundle.tx]]
to = "{SpamMe}"
from_pool = "bluepool"
signature = "tipCoinbase()"
value = "10000000000000000"

fuzzing arguments

Spam steps allow you to define a fuzz parameter that generates pseudo-random values for your function call arguments.

Here’s an example from the mempool scenario:

[[spam]]

[spam.tx]
to = "{SpamMe2}"
from_pool = "redpool"
signature = "consumeGas(uint256 gasAmount)"
args = ["3000000"]
fuzz = [{ param = "gasAmount", min = "1000000", max = "3000000" }]

The param field picks out the argument to inject by the name given in signature. If your signature doesn’t include argument names, you can make up your own; fuzzing arguments requires named params.

💡 Note that we require args to be specified, even when fuzz will replace its value. This may change.

fuzzing transaction-level fields

In addition to function arguments and the tx value, you can fuzz max_priority_fee_per_gas (EIP-1559 priority fee) by setting the max_priority_fee_per_gas flag on a fuzz entry. min and max accept raw wei ("10000000000"), hex ("0x2540be400"), or unit strings ("10 gwei", "0.001 eth"):

[spam.tx]
to = "{SpamMe2}"
from_pool = "redpool"
signature = "consumeGas(uint256 gasAmount)"
args = ["3000000"]
fuzz = [
    { param = "gasAmount", min = "1000000", max = "3000000" },
    { max_priority_fee_per_gas = true, min = "10 gwei", max = "20 gwei" },
]

Exactly one of param, value, or max_priority_fee_per_gas must be set per fuzz entry; combining them is rejected at scenario-load time.

setting a static priority fee

You can also pin a per-tx priority fee without fuzzing by setting max_priority_fee_per_gas directly on the spam step. The value accepts raw wei, hex ("0x..."), or a unit string ("10 gwei", "0.001 eth"), and may use a {placeholder} that resolves to one of those forms. If unset, contender falls back to its default (gas_price / 10).

[spam.tx]
to = "{SpamMe2}"
from_pool = "redpool"
signature = "consumeGas(uint256 gasAmount)"
args = ["3000000"]
max_priority_fee_per_gas = "10 gwei"

run it

Once your scenario config is complete, pass it to contender:

contender setup ./MyScenario.toml $RPC_URL --min-balance 0.25
contender spam ./MyScenario.toml $RPC_URL --tps 10 -d 3 -p $PRV_KEY --min-balance 0.05