In our previous notebook, we simulated users interacting with the EIP 1559 transaction fee market mechanism for inclusion. Our users were strategic to some extent: they observed the prevailing basefee, evaluated their values and costs and decided whether to send a transaction or not. We saw that once the system reaches stationarity, users who aren't deterred by the basefee can expect next-block inclusion.
We made one big assumption there, which is that users respect the commonly stated heuristic of setting their premiums (in general, what the miner receives from the transaction) at a fixed value compensating the miner for the extra work of including one extra transaction. In this notebook, we relax this assumption and look at what happens when users bid strategically, trying to outcompete each other in transitionary periods, before the basefee settles.
Firstly, let's load up a few classes from our abm1559
library.
import os, sys
sys.path.insert(1, os.path.realpath(os.path.pardir))
%config InlineBackend.figure_format = 'svg'
from abm1559.utils import constants
from abm1559.txpool import TxPool
from abm1559.users import User1559
from abm1559.userpool import UserPool
from abm1559.chain import (
Chain,
Block1559,
)
from abm1559.simulator import (
spawn_poisson_heterogeneous_demand,
update_basefee,
)
import pandas as pd
import numpy as np
from tqdm import tqdm
Our previous simulation only featured one type of users, User1559
. Users received some random value (in Gwei, per gas unit) for transaction inclusion and costs (in Gwei, per gas and time unit) for waiting.
For instance, Alice has 15 Gwei/gas value for inclusion, and cost 0.5 Gwei/gas/block. If Alice waits for 5 blocks to be included, her payoff for each gas unit she obtains is 15 - 5 * 0.5 = 12.5. To this, we subtract the fee Alice must pay for inclusion, or gas price. Assuming the basefee currently sits at 10 Gwei/gas and Alice set her premium at 1 Gwei/gas, her total gas price is 11 Gwei/gas. Her final payoff is then 12.5 - 11 = 1.5 Gwei/gas. In other words:
$$ \texttt{payoff} = \texttt{value} - \texttt{cost from waiting} - \texttt{transaction fee} $$Users estimate their final payoff by estimating how long they will wait for inclusion. Our default User1559
estimates a fixed waiting time of 5 blocks. Let's go ahead and change that to zero, as we have done with the OptimisticUser
in the previous notebook. The OptimisticUser
expects next block inclusion.
class OptimisticUser(User1559):
def expected_time(self, env):
return 0
OptimisticUser
s expect a small waiting time, but still set their premium to a fixed value. Can a strategic user do better? Let's find out!
We'll subclass our User1559
again to define the strategic user.
class StrategicUser(User1559):
"""
A strategic affine user sending 1559 transactions.
- Expects to be included in the next block
- Prefers not to participate if its expected payoff is negative
- Strategic gas_premium
"""
epsilon = 0.1 # how much the user overbids by
def expected_time(self, env):
return 0
def decide_parameters(self, env):
if env["min_premium"] is None:
min_premium = 1 * (10 ** 9)
else:
min_premium = env["min_premium"]
gas_premium = min_premium + self.epsilon * (10 ** 9)
max_fee = self.value
return {
"max_fee": max_fee, # in wei
"gas_premium": gas_premium, # in wei
"start_block": self.wakeup_block,
}
def export(self):
return {
**super().export(),
"user_type": "strategic_user_1559",
}
def __str__(self):
return f"1559 strategic affine user with value {self.value} and cost {self.cost_per_unit}"
As our OptimisticUser
does, StrategicUser
expects next-block inclusion. We also change decide_parameters
: the strategic user sets its premium to the minimum premium seen in the previous block, plus 0.1 Gwei. Strategic users are trying to outbid the cheapest user (from the miner's point of view) who was included in the previous block.
Users attach premiums to their transactions as well as fee caps. The fee cap is an instruction to not charge a gas price greater than the cap, whatever happens with the basefee, and protects users from accidental overbidding. Miners receive either the whole premium or the slack between current basefee and feecap, whichever is lower. We call this the tip.
$$ \texttt{tip} = \min(\texttt{fee cap} - \texttt{basefee}, \texttt{gas premium}) $$We set the fee cap of strategic users like we set it for non-strategic ones: since users receive at most their value from getting included, they set their cap to their value.
We'll now put OptimisticUser
s against StrategicUser
s. We modify the simulate
function we used previously to specify at each simulation step how much of each type we expect to spawn. These are the simulation steps:
shares_scenario
.decide_transactions
).def simulate(demand_scenario, shares_scenario):
# Instantiate a couple of things
txpool = TxPool()
basefee = constants["INITIAL_BASEFEE"]
chain = Chain()
metrics = []
user_pool = UserPool()
min_premium = 1 * (10 ** 9)
for t in tqdm(range(len(demand_scenario))):
# `env` is the "environment" of the simulation
env = {
"basefee": basefee,
"current_block": t,
"min_premium": min_premium,
}
# We return some demand which on expectation yields demand_scenario[t] new users per round
users = spawn_poisson_heterogeneous_demand(t, demand_scenario[t], shares_scenario[t])
# Add users to the pool and check who wants to transact
# We query each new user with the current basefee value
# Users either return a transaction or None if they prefer to balk
decided_txs = user_pool.decide_transactions(users, env)
# New transactions are added to the transaction pool
txpool.add_txs(decided_txs)
# The best valid transactions are taken out of the pool for inclusion
selected_txs = txpool.select_transactions(env)
txpool.remove_txs([tx.tx_hash for tx in selected_txs])
# We create a block with these transactions
block = Block1559(txs = selected_txs, parent_hash = chain.current_head, height = t, basefee = basefee)
# Record the min premium in the block
min_premium = block.min_premium()
# The block is added to the chain
chain.add_block(block)
# A couple of metrics we will use to monitor the simulation
pool_strat_users = len(
[tx for tx in txpool.txs.values() if type(user_pool.users[tx.sender]) == StrategicUser])
pool_nonstrat_users = len(
[tx for tx in txpool.txs.values() if type(user_pool.users[tx.sender]) == OptimisticUser])
row_metrics = {
"block": t,
"basefee": basefee / (10 ** 9),
"users": len(users),
"strategic": len([user for user in users if type(user) == StrategicUser]),
"nonstategic": len([user for user in users if type(user) == OptimisticUser]),
"decided_txs": len(decided_txs),
"included_txs": len(selected_txs),
"blk_min_premium": block.min_premium() / (10 ** 9), # to Gwei
"blk_avg_gas_price": block.average_gas_price(),
"blk_avg_tip": block.average_tip(),
"pool_length": txpool.pool_length(),
"pool_strat_users": pool_strat_users,
"pool_nonstrat_users": pool_nonstrat_users,
}
metrics.append(row_metrics)
# Finally, basefee is updated and a new round starts
basefee = update_basefee(block, basefee)
return (pd.DataFrame(metrics), user_pool, chain)
We'll start with a simulation for 200 blocks. We set equal shares of strategic and non-strategic users, with on average a total of 2500 users spawning each round. Remember that our blocks can accommodate at most 952 users, but target inclusion of about half of this number, i.e., 475 of them. There is plenty of space then for strategic users (on average 1250 of them each round) to grab all the available space.
blocks = 100
demand_scenario = [2500 for i in range(blocks)]
strategic_share = 0.5
shares_scenario = [{
StrategicUser: strategic_share,
OptimisticUser: 1 - strategic_share,
} for i in range(blocks)]
(df, user_pool, chain) = simulate(demand_scenario, shares_scenario)
100%|██████████| 100/100 [00:13<00:00, 7.34it/s]
What does df
reveal?
df
block | basefee | users | strategic | nonstategic | decided_txs | included_txs | blk_min_premium | blk_avg_gas_price | blk_avg_tip | pool_length | pool_strat_users | pool_nonstrat_users | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 0 | 1.000000 | 2571 | 1285 | 1286 | 2313 | 1190 | 1.0 | 2.097143 | 1.097143 | 1123 | 0 | 1123 |
1 | 1 | 1.124900 | 2521 | 1260 | 1261 | 2251 | 1190 | 1.0 | 2.219354 | 1.094454 | 2184 | 0 | 2184 |
2 | 2 | 1.265400 | 2465 | 1232 | 1233 | 2186 | 1190 | 1.0 | 2.356829 | 1.091429 | 3180 | 0 | 3180 |
3 | 3 | 1.423448 | 2475 | 1237 | 1238 | 2163 | 1190 | 1.0 | 2.514205 | 1.090756 | 4153 | 0 | 4153 |
4 | 4 | 1.601237 | 2513 | 1256 | 1257 | 2210 | 1190 | 1.0 | 2.693674 | 1.092437 | 5173 | 0 | 5173 |
... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
95 | 95 | 14.355473 | 2551 | 1275 | 1276 | 588 | 588 | 1.0 | 15.404113 | 1.048639 | 8930 | 0 | 8930 |
96 | 96 | 14.333653 | 2531 | 1265 | 1266 | 565 | 565 | 1.0 | 15.382680 | 1.049027 | 8930 | 0 | 8930 |
97 | 97 | 14.242634 | 2644 | 1322 | 1322 | 609 | 609 | 1.0 | 15.296822 | 1.054187 | 8930 | 0 | 8930 |
98 | 98 | 14.283796 | 2530 | 1265 | 1265 | 589 | 589 | 1.0 | 15.331843 | 1.048048 | 8930 | 0 | 8930 |
99 | 99 | 14.265084 | 2419 | 1209 | 1210 | 553 | 553 | 1.0 | 15.316983 | 1.051899 | 8930 | 0 | 8930 |
100 rows × 13 columns
We see on average 2500 users
, with half of them strategic
and the other half nonstrategic
. Many decide to transact in the first few rounds, more than are eventually included (decided_txs
> included_txs
). The pool length, i.e., how many decided transactions were not included yet, increases steadily, with most users in the pool non-strategic users! We have the first clue that being strategic is not a bad idea, so let's dig a little more in results.
Let's look at the basefee and the minimum premium in a block.
df.plot("block", ["basefee", "blk_min_premium"])
<AxesSubplot:xlabel='block'>
df[(df.block >= 24) & (df.block <= 27)]
block | basefee | users | strategic | nonstategic | decided_txs | included_txs | blk_min_premium | blk_avg_gas_price | blk_avg_tip | pool_length | pool_strat_users | pool_nonstrat_users | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
24 | 24 | 16.855204 | 2540 | 1270 | 1270 | 270 | 1190 | 1.0 | 17.867220 | 1.012017 | 11787 | 0 | 11787 |
25 | 25 | 18.960418 | 2528 | 1264 | 1264 | 4 | 151 | 1.0 | 19.488251 | 0.527832 | 11640 | 0 | 11640 |
26 | 26 | 17.191601 | 2558 | 1279 | 1279 | 206 | 748 | 1.0 | 17.865505 | 0.673904 | 11098 | 0 | 11098 |
27 | 27 | 17.743108 | 2492 | 1246 | 1246 | 148 | 148 | 1.0 | 18.792432 | 1.049324 | 11098 | 0 | 11098 |
Basefee increases until reaching its apex at block 26. This is the same behaviour we observed in the previous notebook: too many users want in so the basefee quickly reaches a high value, where no one is willing to pay this much. After the apex, the basefee climbs down, accommodating some of the most high-valued new users as well as equally high-valued users in the pool who were priced out.
We also plot (as seen in orange in the previous graph) the minimum premium observed in the block. The dynamics are curious: to understand them, remember that strategic users systematically bid just above the minimum premium observed in the previous block. During the transitionary period between blocks 0 and 26, too many users want in so the basefee increases. At the same time, the minimum premium observed in a block increases too. We can imagine that enough strategic users successfully outbid non-strategic ones to fill the whole block. Since strategic users always bid above the smallest premium in the previous block, they bid a premium of 1.1 Gwei the first time, then 1.2, then 1.3 etc. At this point, we recognise the unstable dynamics of the current first-price auction paradigm, where users bid against each other instead of bidding their true value.
As the basefee increases, fewer and fewer new strategic users decide to transact, since low-valued users are priced out, leaving some space for non-strategic users to be included. This releases some pressure on the premiums, until block 26 where the basefee is so high that no one is included (and the minimum premium is zero).
df.plot("block", ["pool_length", "users", "pool_strat_users", "pool_nonstrat_users"])
<AxesSubplot:xlabel='block'>
We observe in the chart above the transaction pool length, and indeed confirm that most users in the pool are non-strategic. Strategic users get ahead by outbidding them.
We'll now look at the trace more closely. We export simulation data to pandas DataFrame
s for ease of manipulation, using the export
methods we defined in our classes.
# Obtain the pool of users (all users spawned by the simulation)
user_pool_df = user_pool.export().rename(columns={ "pub_key": "sender" })
# Export the trace of the chain, all transactions included in blocks
chain_df = chain.export()
# Join the two to associate transactions with their senders
user_txs_df = chain_df.join(user_pool_df.set_index("sender"), on="sender")
For now we'll only look at the distribution of strategic vs. non-strategic users in the blocks.
# Obtain per user type statistics
txs_per_user_type = user_txs_df.groupby(
["block_height", "user_type"]
).agg(
{ "user_type": len }
).unstack(level=-1).reset_index()
txs_per_user_type["user_type"] = txs_per_user_type["user_type"].fillna(0)
txs_per_user_type.columns = ["block_height", "strategic", "nonstrategic"]
txs_per_user_type["total"] = txs_per_user_type.apply(
lambda row: row.strategic + row.nonstrategic,
axis = 1
)
txs_per_user_type["percent_strategic"] = txs_per_user_type.apply(
lambda row: row.strategic / row.total * 100,
axis = 1
)
We first check how many users in each block are strategic/non-strategic.
txs_per_user_type.plot("block_height", ["strategic", "nonstrategic", "total"])
<AxesSubplot:xlabel='block_height'>
From this plot, a few observations stand out:
This is confirmed by the following plot, which shows the percentage of strategic users in each block.
txs_per_user_type.plot("block_height", "percent_strategic")
<AxesSubplot:xlabel='block_height'>
In the chart above, between blocks 0 and 26, before basefee reaches its apex, strategic users are included in much higher proportions than non-strategic users. Not surprising, since they post higher premiums. More surprising however is that this proportion is decreasing, until it is reversed: by block 20, more non-strategic users are included than strategic.
This is trickier to explain but we can sketch the following narrative.
It is expected to find this instability during transitionary periods, where the basefee needs to adapt to a changing demand, e.g., a spike in transactions or higher values for transacting. We find however that once the basefee settles, strategic users no longer have the edge over nonstrategic ones: the basefee is the true determinant for inclusion.
In this section, we change the bidding behaviour once more. Previously, we looked at OptimisticUser
s who set their gas premium to 1 Gwei, a small, fixed value meant to compensate miner work, or StrategicUser
s who overbid when blocks are full. With EIP 1559 implemented, it is reasonable to believe most users would follow this simple bidding strategy: look at the gasprice level (the current basefee plus this 1 Gwei premium) and decide sending their transaction or not, sometimes adjusting the premium when congestion spikes.
We'll relax this assumption however, and look at the case where users set their premium according to their value for the transaction: the higher their value, the greater the premium.
class OptimisticUser(User1559):
def expected_time(self, env):
return 0
def decide_parameters(self, env):
# Users add a fraction of their value to their premium
# Higher value users thus have higher premiums, all else equal
gas_premium = 1 * (10 ** 9) + self.value // 1000
max_fee = self.value
return {
"max_fee": max_fee,
"gas_premium": gas_premium, # in wei
"start_block": self.wakeup_block,
}
class StrategicUser(User1559):
"""
A strategic affine user sending 1559 transactions.
- Expects to be included in the next block
- Prefers not to participate if its expected payoff is negative
- Strategic gas_premium
"""
epsilon = 0.1 # how much the user overbids by
def expected_time(self, env):
return 0
def decide_parameters(self, env):
if env["min_premium"] is None:
min_premium = 1 * (10 ** 9)
else:
min_premium = env["min_premium"]
gas_premium = min_premium + self.value // 1000 + self.epsilon * (10 ** 9)
max_fee = self.value
return {
"max_fee": max_fee, # in wei
"gas_premium": gas_premium, # in wei
"start_block": self.wakeup_block,
}
def export(self):
return {
**super().export(),
"user_type": "strategic_user_1559",
}
def __str__(self):
return f"1559 strategic affine user with value {self.value} and cost {self.cost_per_unit}"
Run the simulation again.
strategic_share = 0.5
shares_scenario = [{
StrategicUser: strategic_share,
OptimisticUser: 1 - strategic_share,
} for i in range(blocks)]
(df, user_pool, chain) = simulate(demand_scenario, shares_scenario)
100%|██████████| 100/100 [00:12<00:00, 7.79it/s]
Basefee and minimum premiums have similar dynamics.
df.plot("block", ["basefee", "blk_min_premium"])
<AxesSubplot:xlabel='block'>
We obtain the average value of included strategic and non-strategic users.
# Obtain the pool of users (all users spawned by the simulation)
user_pool_df = user_pool.export().rename(columns={ "pub_key": "sender" })
# Export the trace of the chain, all transactions included in blocks
chain_df = chain.export()
# Join the two to associate transactions with their senders
user_txs_df = chain_df.join(user_pool_df.set_index("sender"), on="sender")
# Obtain per user type statistics
txs_per_user_type = user_txs_df.groupby(
["block_height", "user_type"]
).agg(
{ "value": np.mean }
).unstack(level=-1).reset_index()
txs_per_user_type["value"] = txs_per_user_type["value"].fillna(0)
txs_per_user_type.columns = ["block_height", "avg_strategic_value", "avg_nonstrategic_value"]
txs_per_user_type["blk_min_premium"] = df["blk_min_premium"]
txs_per_user_type.plot("block_height", ["avg_strategic_value", "avg_nonstrategic_value", "blk_min_premium"])
<AxesSubplot:xlabel='block_height'>
We recognise familiar dynamics: for the first 10 blocks or so, non-strategic users are not included, being outbid by strategic users. Once the basefee prices out enough transactions, there is room for strategic users to join. High-value users get in first, since their premium increases with their value. Yet once the basefee stabilises, both groups of included users have equal average value.
It is useful to obtain a counterfactual, to check what the results would be with all users non-strategic. We set the strategic_share
to zero and run the simulation again.
strategic_share = 0
shares_scenario = [{
StrategicUser: strategic_share,
OptimisticUser: 1 - strategic_share,
} for i in range(blocks)]
(df2, user_pool2, chain2) = simulate(demand_scenario, shares_scenario)
100%|██████████| 100/100 [00:13<00:00, 7.61it/s]
# Obtain the pool of users (all users spawned by the simulation)
user_pool_df2 = user_pool2.export().rename(columns={ "pub_key": "sender" })
# Export the trace of the chain, all transactions included in blocks
chain_df2 = chain2.export()
# Join the two to associate transactions with their senders
user_txs_df2 = chain_df2.join(user_pool_df2.set_index("sender"), on="sender")
# Obtain per user type statistics
txs_per_user_type2 = user_txs_df2.groupby(
["block_height", "user_type"]
).agg(
{ "value": ["mean"] }
).unstack(level=-1).fillna(0).reset_index()
We look at the average value of included users in our original scenario and the counterfactual.
compare = pd.DataFrame({
"block_height": txs_per_user_type.iloc[:,0],
"strat": txs_per_user_type.iloc[:,1],
"nonstrat": txs_per_user_type.iloc[:,2],
"nonstrat_counterfact": txs_per_user_type2.iloc[:,1] })
compare.plot("block_height", ["strat", "nonstrat", "nonstrat_counterfact"]).set_ylim([10, 21])
(10.0, 21.0)
In the counterfactual scenario, where all users are non-strategic, the average value of included users in the first few blocks is around 16 Gwei per gas. Meanwhile, the average value of included users in the original scenario starts from 12 Gwei per gas!
We see here the impact of various bidding behaviours on efficiency. Auctions are typically deemed efficient when high-value users get what what they want, e.g., when the item being auctioned goes to who wants it the most. When users bid as they do in a first-price auction, the fee market is less efficient than when users are non-strategic. Fortunately, this inefficiency doesn't last: once the basefee reaches its stationary level, in both the original scenario as well as the counterfactual, similar-valued users are included (namely, the top 475 of them).
In future notebooks, we'll investigate the efficiency properties of different fee market mechanisms.