In the current, first-price auction-based paradigm, users are presented with three wallet defaults to specify their gas price bids (typically, "slow", "medium" or "fast"), while experienced users can set their own price with advanced settings.
Since EIP 1559 equilibrates to a market price, we look into defaults that embody the price-taking behaviour of users under 1559. Their wallets presents them with a "current transaction price", and a simple binary option: transact or not.
This default is likely sufficient for periods where the basefee is relatively stable, but fails to accurately represent user demand when that demand is shifting upwards, since strategic users enter bidding wars to get ahead. In this case, a wallet could revert to the well-known UX presenting three price levels, with differentiated guarantees for inclusion and delay, which we'll call expert mode.
Finding the correct price levels to offer as well as the criteria determining when the UI should switch from the "price-taking" UI to the "shifting demand" UI is discussed via simulations of various demand behaviours. We first import classes from our agent-based transaction market simulation.
import os, sys
sys.path.insert(1, os.path.realpath(os.path.pardir))
# You may remove the two lines above if you have installed abm1559 from pypi
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
import seaborn as sns
We introduce a new element sitting between the user and the market, a WalletOracle
providing the user with the two UIs discussed above. The wallet features two major parameters:
Switch to the expert mode whenever the current basefee is $r$% above its moving average of the last $B$ blocks.
The gas premium $g_i$ of price level $i$ is $c_i$ times the miner minimum premium $\delta$, so $g_i = c_i \cdot \delta$ with $c_i < c_j, \, \forall i < j$.
In a later analysis, we investigate a more complex rule
The gas premium $g_i$ of price level $i$ is a function of the recorded premiums in the $B$ last blocks, $g_i = f_i(B_{t-B}, \dots, B_{t-1})$, with $g_i < g_j, \, \forall i < j$.
class WalletOracle:
def __init__(self, sensitivity=0.05, ma_window=5):
self.sensitivity = sensitivity
self.ma_window = ma_window
self.basefees = []
self.ui_mode = "posted"
self.expert_defaults = {
key: { "max_fee": 0, "gas_premium": 0 } for key in ["slow", "medium", "fast"]
}
self.min_premium = 1 * (10 ** 9) # in wei
def update_defaults(self, basefee):
self.basefees = self.basefees[-(self.ma_window - 1):] + [basefee]
ma_basefee = np.mean(self.basefees)
if (1 + self.sensitivity) * ma_basefee >= basefee:
# basefee broadly in line with or under its average value in the last blocks
self.ui_mode = "posted"
else:
self.ui_mode = "expert"
self.expert_defaults = {
"slow": { "max_fee": 2*basefee, "gas_premium": 2*self.min_premium },
"medium": { "max_fee": 3*basefee, "gas_premium": 3*self.min_premium },
"fast": { "max_fee": 4*basefee, "gas_premium": 4*self.min_premium },
}
def get_expert_defaults(self, max_fee):
return {
key: {
"max_fee": min(max_fee, value["max_fee"]),
"gas_premium": value["gas_premium"],
} for key, value in self.expert_defaults.items()
}
def get_defaults(self, speed, max_fee):
if self.ui_mode == "posted":
return (self.ui_mode, {
"max_fee": min([max_fee, max([1.5 * self.basefees[-1], self.basefees[-1] + self.min_premium])]),
"gas_premium": self.min_premium
})
else:
return (self.ui_mode, self.get_expert_defaults(max_fee)[speed])
We define a new User
class who uses the wallet above to make their pricing decision. This user is an AffineUser
, and thus experiences a cost for waiting, drawn from a some distribution. We assume that users know their quantile in this distribution. For instance, if we draw costs from a uniform distribution between 0 and 1 Gwei per gas unit, then a user with cost 0.85 knows they are in the 85% quantile.
Users who care a lot about fast inclusion tend to choose the fast default. We'll assume that since users know their quantile, they can also identify whether the slow, medium, or fast default is appropriate for them. Assuming the wallet suggests three price levels, we have two numbers $0 < q_1 < q_2 < 0$ such that:
class UserWallet(User1559):
def expected_time(self, params):
return 0
def decide_parameters(self, env):
wallet = env["wallet"]
if self.cost_per_unit <= env["cost_quantiles"][0]:
speed = "slow"
elif self.cost_per_unit <= env["cost_quantiles"][1]:
speed = "medium"
else:
speed = "fast"
defaults = wallet.get_defaults(speed, self.value)[1]
return {
**defaults,
"start_block": self.wakeup_block,
}
The usual simulation loop...
def simulate(demand_scenario, shares_scenario):
# Instantiate a couple of things
txpool = TxPool()
basefee = constants["INITIAL_BASEFEE"]
chain = Chain()
metrics = []
user_pool = UserPool()
wallet = WalletOracle()
for t in range(len(demand_scenario)):
# Update oracle
wallet.update_defaults(basefee)
# 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])
cost_quantiles = np.quantile([u.cost_per_unit for u in users], q = [0.4, 0.75, 0.95])
# `params` are the "environment" of the simulation
env = {
"basefee": basefee,
"current_block": t,
"wallet": wallet,
"cost_quantiles": cost_quantiles,
}
# 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)
# The block is added to the chain
chain.add_block(block)
row_metrics = {
"block": t,
"basefee": basefee / (10 ** 9),
"ui_mode": wallet.ui_mode,
"users": len(users),
"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,
}
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)
blocks = 100
demand_scenario = [2500 for i in range(blocks)]
shares_scenario = [{
UserWallet: 1
} for i in range(blocks)]
(df, user_pool, chain) = simulate(demand_scenario, shares_scenario)
We see the ui_mode
switching to expert
mode from block 1 onwards, picking up a demand shift important enough to allow users to choose their price level.
By block 25, when the basefee stabilises, the UI switches back to the posted
price binary option.
df[(df.block <= 30)]
block | basefee | ui_mode | users | decided_txs | included_txs | blk_min_premium | blk_avg_gas_price | blk_avg_tip | pool_length | |
---|---|---|---|---|---|---|---|---|---|---|
0 | 0 | 1.000000 | posted | 2391 | 2128 | 952 | 1.0 | 2.000000 | 1.000000 | 1176 |
1 | 1 | 1.124900 | expert | 2546 | 2147 | 952 | 3.0 | 3.958419 | 2.833519 | 2371 |
2 | 2 | 1.265400 | expert | 2607 | 2165 | 952 | 3.0 | 4.464788 | 3.199388 | 3584 |
3 | 3 | 1.423448 | expert | 2560 | 2034 | 952 | 3.0 | 4.854164 | 3.430716 | 4666 |
4 | 4 | 1.601237 | expert | 2504 | 1965 | 952 | 3.0 | 5.063422 | 3.462185 | 5679 |
5 | 5 | 1.801232 | expert | 2487 | 1915 | 952 | 3.0 | 5.259215 | 3.457983 | 6642 |
6 | 6 | 2.026206 | expert | 2513 | 1868 | 952 | 3.0 | 5.495743 | 3.469538 | 7558 |
7 | 7 | 2.279279 | expert | 2506 | 1855 | 952 | 3.0 | 5.722556 | 3.443277 | 8461 |
8 | 8 | 2.563961 | expert | 2602 | 1917 | 952 | 3.0 | 6.014591 | 3.450630 | 9426 |
9 | 9 | 2.884199 | expert | 2459 | 1742 | 952 | 3.0 | 6.316972 | 3.432773 | 10216 |
10 | 10 | 3.244436 | expert | 2542 | 1768 | 952 | 3.0 | 6.664604 | 3.420168 | 11032 |
11 | 11 | 3.649666 | expert | 2480 | 1652 | 952 | 3.0 | 7.056178 | 3.406513 | 11732 |
12 | 12 | 4.105509 | expert | 2518 | 1645 | 952 | 3.0 | 7.514122 | 3.408613 | 12425 |
13 | 13 | 4.618287 | expert | 2474 | 1568 | 952 | 3.0 | 8.007993 | 3.389706 | 13041 |
14 | 14 | 5.195111 | expert | 2493 | 1462 | 952 | 3.0 | 8.564859 | 3.369748 | 13551 |
15 | 15 | 5.843980 | expert | 2520 | 1434 | 952 | 3.0 | 9.184317 | 3.340336 | 14033 |
16 | 16 | 6.573894 | expert | 2486 | 1321 | 952 | 3.0 | 9.902675 | 3.328782 | 14402 |
17 | 17 | 7.394973 | expert | 2460 | 1202 | 952 | 2.0 | 10.417112 | 3.022139 | 14652 |
18 | 18 | 8.318605 | expert | 2467 | 1111 | 952 | 2.0 | 11.240874 | 2.922269 | 14811 |
19 | 19 | 9.357599 | expert | 2526 | 999 | 952 | 2.0 | 12.120204 | 2.762605 | 14858 |
20 | 20 | 10.526363 | expert | 2554 | 841 | 952 | 2.0 | 13.188128 | 2.661765 | 14747 |
21 | 21 | 11.841106 | expert | 2502 | 675 | 952 | 2.0 | 14.373669 | 2.532563 | 14470 |
22 | 22 | 13.320060 | expert | 2629 | 503 | 864 | 2.0 | 15.484746 | 2.164687 | 14109 |
23 | 23 | 14.676042 | expert | 2494 | 296 | 296 | 2.0 | 17.246988 | 2.570946 | 14109 |
24 | 24 | 13.981865 | expert | 2525 | 410 | 410 | 2.0 | 16.603816 | 2.621951 | 14109 |
25 | 25 | 13.738930 | posted | 2524 | 629 | 629 | 1.0 | 14.738930 | 1.000000 | 14109 |
26 | 26 | 14.290033 | posted | 2479 | 569 | 569 | 1.0 | 15.290033 | 1.000000 | 14109 |
27 | 27 | 14.638174 | posted | 2448 | 557 | 557 | 1.0 | 15.638174 | 1.000000 | 14109 |
28 | 28 | 14.948686 | posted | 2562 | 542 | 542 | 1.0 | 15.948686 | 1.000000 | 14109 |
29 | 29 | 15.206925 | posted | 2423 | 488 | 488 | 1.0 | 16.206925 | 1.000000 | 14109 |
30 | 30 | 15.254066 | posted | 2459 | 458 | 458 | 1.0 | 16.254066 | 1.000000 | 14109 |
df.plot("block", ["basefee", "blk_avg_tip"])
<AxesSubplot:xlabel='block'>
# 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")
bins = 20
user_txs_df["user_sw"] = user_txs_df.apply(
lambda row: row.value - (row.block_height - row.start_block) * row.cost_per_unit - (row.basefee + row.tip / (10 ** 9)),
axis=1
)
user_txs_df["value_bin"] = pd.cut(user_txs_df["value"], bins=np.linspace(0, 20, bins+1))
user_txs_df["cost_bin"] = pd.cut(user_txs_df["cost_per_unit"], bins=np.linspace(0, 1, bins+1))
grouped = user_txs_df.groupby(["block_height", "value_bin", "cost_bin"]).mean().reset_index()
I don't remember exactly what I wanted to plot here... WIP!
ax = sns.heatmap(grouped[grouped.block_height == 2].pivot("value_bin", "cost_bin", "user_sw"))
ax.invert_yaxis()
def draw_heatmap(*args, **kwargs):
data = kwargs.pop('data')
d = data.pivot(index=args[0], columns=args[1], values=args[2])
ax = sns.heatmap(d, **kwargs, vmin=0, vmax=20, cmap="OrRd", center=5)
ax.invert_yaxis()
grid_size = 6
fg = sns.FacetGrid(grouped[grouped.block_height < grid_size ** 2], col='block_height', col_wrap = grid_size)
fg.map_dataframe(draw_heatmap, 'value_bin', 'cost_bin', 'user_sw')
<seaborn.axisgrid.FacetGrid at 0x7fc8ad2608b0>