{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "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.\n", "\n", "Since [EIP 1559 equilibrates to a market price](https://ethereum.github.io/abm1559/notebooks/stationary1559.html), 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.\n", "\n", "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](https://ethereum.github.io/abm1559/notebooks/strategicUser.html) 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**.\n", "\n", "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." ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [], "source": [ "import os, sys\n", "sys.path.insert(1, os.path.realpath(os.path.pardir))\n", "from typing import Sequence\n", "\n", "from abm1559.utils import constants\n", "\n", "# Target at most 100 transactions per block\n", "gamma = 250000\n", "constants[\"SIMPLE_TRANSACTION_GAS\"] = gamma\n", "\n", "from abm1559.txpool import TxPool\n", "\n", "from abm1559.users import User1559\n", "\n", "from abm1559.userpool import UserPool\n", "\n", "from abm1559.chain import (\n", " Chain,\n", " Block1559,\n", ")\n", "\n", "from abm1559.simulator import (\n", " spawn_poisson_heterogeneous_demand,\n", " update_basefee,\n", ")\n", "\n", "from abm1559.txs import Tx1559\n", "from abm1559.config import rng\n", "\n", "import pandas as pd\n", "import numpy as np\n", "import seaborn as sns\n", "from tqdm.notebook import tqdm\n", "import plotly.express as px\n", "import plotly.io as pio\n", "pd.options.plotting.backend = \"plotly\"\n", "pio.renderers.default = \"plotly_mimetype+notebook_connected\"" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Simulation components\n", "\n", "### Wallet oracle\n", "\n", "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:\n", "\n", "- A criterion deciding when to switch from the binary UI to the expert mode. The criterion takes the form of a parameterised expression:\n", "\n", "> Switch to the expert mode whenever the current basefee is $r$% above its moving average of the last $B$ blocks.\n", "\n", "- The price levels to suggest the user in expert mode. Although we settle on _three_ price levels, mimicking the current wallets UI, it may be possible to consider two or four or any other number we think reasonable. For each price level, the wallet suggests a different gas premium, following the rules:\n", "\n", "> - The max priority fee $g_i$ of price level $i$ is $c_i$ times the miner minimum fee $\\delta$, so $g_i = c_i \\cdot \\delta$ with $c_i < c_j, \\, \\forall i < j$.\n", "> - The max fee $m_i$ of price level $i$ is $c_i$ times the current basefee.\n", "\n", "In the simple setting, we'll first look at $c_1 = 1$ (slow users send the smallest acceptable priority fee), $c_2 = 2$ and $c_3 = 3$. Later on, we'll investigate more complex rules:\n", " \n", "> 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$." ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [], "source": [ "class WalletOracle:\n", " \n", " def __init__(self, sensitivity=0.05, ma_window=5):\n", " self.sensitivity = sensitivity\n", " self.ma_window = ma_window\n", " self.basefees = []\n", " self.ui_mode = \"posted\"\n", " self.expert_defaults = {\n", " key: { \"max_fee\": 0, \"gas_premium\": 0 } for key in [\"slow\", \"medium\", \"fast\"]\n", " }\n", " self.min_premium = 1 * (10 ** 9) # in wei\n", " \n", " def update_defaults(self, basefee):\n", " self.basefees = self.basefees[-(self.ma_window - 1):] + [basefee]\n", " ma_basefee = np.mean(self.basefees)\n", " \n", " if (1 + self.sensitivity) * ma_basefee >= basefee:\n", " # basefee broadly in line with or under its average value in the last blocks\n", " self.ui_mode = \"posted\"\n", " \n", " else:\n", " self.ui_mode = \"expert\"\n", " self.expert_defaults = {\n", " \"slow\": { \"max_fee\": 2*basefee, \"gas_premium\": self.min_premium },\n", " \"medium\": { \"max_fee\": 3*basefee, \"gas_premium\": 2*self.min_premium },\n", " \"fast\": { \"max_fee\": 4*basefee, \"gas_premium\": 3*self.min_premium },\n", " }\n", " \n", " def get_expert_defaults(self, max_fee):\n", " return {\n", " key: {\n", " \"max_fee\": min(max_fee, value[\"max_fee\"]),\n", " \"gas_premium\": value[\"gas_premium\"],\n", " } for key, value in self.expert_defaults.items()\n", " }\n", " \n", " def get_defaults(self, speed, max_fee):\n", " if self.ui_mode == \"posted\":\n", " return (self.ui_mode, {\n", " \"max_fee\": min([max_fee, max([1.5 * self.basefees[-1], self.basefees[-1] + self.min_premium])]),\n", " \"gas_premium\": self.min_premium\n", " })\n", " else:\n", " return (self.ui_mode, self.get_expert_defaults(max_fee)[speed])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### User behaviour with wallets" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We define a new `User` class who uses the wallet defined above to make their pricing decision. This user is an `AffineUser` which experiences a cost for waiting, drawn from 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 top 15% of the most impatient users.\n", "\n", "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:\n", "\n", "- Users below quantile $q_1$ choose the slow default.\n", "- Users between quantiles $q_1$ and $q_2$ choose the medium default.\n", "- Users above quantile $q_2$ choose the fast default.\n", "\n", "We'll leave these two numbers $q_1, q_2$ as parameters for the simulation, arbitrarily picking $q_1 = 0.4$ and $q_2 = 0.75$." ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [], "source": [ "class UserWallet(User1559):\n", " \n", " def __init__(self, wakeup_block, **kwargs):\n", " super().__init__(wakeup_block, **kwargs)\n", " self.value = self.rng.uniform(low=0, high=20) * (10 ** 9)\n", " self.cost_per_unit = self.rng.uniform(low=0, high=1) * (10 ** 9)\n", " \n", " def expected_time(self, params):\n", " return 0\n", " \n", " def decide_parameters(self, env):\n", " wallet = env[\"wallet\"]\n", " \n", " if self.cost_per_unit <= env[\"cost_quantiles\"][0]:\n", " speed = \"slow\"\n", " elif self.cost_per_unit <= env[\"cost_quantiles\"][1]:\n", " speed = \"medium\"\n", " else:\n", " speed = \"fast\"\n", " \n", " defaults = wallet.get_defaults(speed, self.value)[1]\n", " \n", " return {\n", " **defaults,\n", " \"start_block\": self.wakeup_block,\n", " }" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Transaction pool\n", "\n", "We also define the following transaction pool behaviour:\n", "\n", "- The maximum number of pending transactions in the pool is limited to 500. With our setting of at most 100 transactions in the block (and a target of 50 transactions per block), the pool holds up to 5 full blocks worth of transactions.\n", "- Miners select the highest tip-paying transactions from the pool, where `tip = min(premium, fee_cap - basefee)`, as long as `tip >= MIN_PREMIUM`, with `MIN_PREMIUM` set to 1 Gwei.\n", "- Whenever too many transactions are added to the pool, the pool is resorted by `tip`, with the lowest tip-paying transactions excluded until the pool limit size is recovered." ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [], "source": [ "MAX_TX_POOL = 500\n", "MIN_PREMIUM = 1e9\n", "\n", "class TxPoolLimit(TxPool):\n", " \n", " def __init__(self, max_txs=MAX_TX_POOL, min_premium=MIN_PREMIUM, **kwargs):\n", " super().__init__(**kwargs)\n", " self.max_txs = max_txs\n", " self.min_premium = MIN_PREMIUM\n", " \n", " def add_txs(self, txs: Sequence[Tx1559], env: dict) -> Sequence[Tx1559]:\n", " for tx in txs:\n", " self.txs[tx.tx_hash] = tx\n", " \n", " if self.pool_length() > self.max_txs:\n", " sorted_txs = sorted(self.txs.values(), key = lambda tx: -tx.tip(env))\n", " self.empty_pool()\n", " self.add_txs(sorted_txs[0:self.max_txs], env)\n", " return sorted_txs[self.max_txs:]\n", " \n", " return []\n", "\n", "class TxPool1559(TxPoolLimit):\n", " \n", " def select_transactions(self, env, user_pool=None, rng=rng) -> Sequence[Tx1559]:\n", " # Miner side\n", " max_tx_in_block = int(constants[\"MAX_GAS_EIP1559\"] / constants[\"SIMPLE_TRANSACTION_GAS\"])\n", "\n", " valid_txs = [tx for tx in self.txs.values() if tx.is_valid(env) and tx.tip(env) >= self.min_premium]\n", " rng.shuffle(valid_txs)\n", "\n", " sorted_valid_demand = sorted(\n", " valid_txs,\n", " key = lambda tx: -tx.tip(env)\n", " )\n", " selected_txs = sorted_valid_demand[0:max_tx_in_block]\n", "\n", " return selected_txs" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Running the simulation\n", "\n", "Open the code snippet below to see the simulation steps. Every round, we spawn a certain quantity of users, some of them are discouraged by the price level and balk, while others send their transactions in. The transaction pool receives the transactions and the miner includes the highest tip-paying transactions first, breaking ties arbitrarily." ] }, { "cell_type": "code", "execution_count": 5, "metadata": {}, "outputs": [], "source": [ "def simulate(demand_scenario, shares_scenario, rng=np.random.default_rng(1)):\n", " # Instantiate a couple of things\n", " txpool = TxPool1559()\n", " basefee = constants[\"INITIAL_BASEFEE\"]\n", " chain = Chain()\n", " metrics = []\n", " user_pool = UserPool()\n", " wallet = WalletOracle()\n", "\n", " for t in tqdm(range(len(demand_scenario)), desc=\"simulation loop\", leave=False):\n", " \n", " # Update oracle\n", " wallet.update_defaults(basefee)\n", " \n", " # We return some demand which on expectation yields demand_scenario[t] new users per round\n", " users = spawn_poisson_heterogeneous_demand(t, demand_scenario[t], shares_scenario[t], rng=rng)\n", " cost_quantiles = np.quantile([u.cost_per_unit for u in users], q = [0.4, 0.75])\n", " \n", " # `params` are the \"environment\" of the simulation\n", " env = {\n", " \"basefee\": basefee,\n", " \"current_block\": t,\n", " \"wallet\": wallet,\n", " \"cost_quantiles\": cost_quantiles,\n", " }\n", " \n", " # Add users to the pool and check who wants to transact\n", " # We query each new user with the current basefee value\n", " # Users either return a transaction or None if they prefer to balk\n", " decided_txs = user_pool.decide_transactions(users, env)\n", "\n", " # New transactions are added to the transaction pool\n", " txpool.add_txs(decided_txs, env)\n", "\n", " # The best valid transactions are taken out of the pool for inclusion\n", " selected_txs = txpool.select_transactions(env)\n", " txpool.remove_txs([tx.tx_hash for tx in selected_txs])\n", "\n", " # We create a block with these transactions\n", " block = Block1559(txs = selected_txs, parent_hash = chain.current_head, height = t, basefee = basefee)\n", " \n", " # The block is added to the chain\n", " chain.add_block(block)\n", "\n", " row_metrics = {\n", " \"block\": t,\n", " \"basefee\": basefee / (10 ** 9),\n", " \"ui_mode\": wallet.ui_mode,\n", " \"users\": len(users),\n", " \"decided_txs\": len(decided_txs),\n", " \"included_txs\": len(selected_txs),\n", " \"blk_min_premium\": block.min_premium() / (10 ** 9), # to Gwei\n", " \"blk_avg_gas_price\": block.average_gas_price(),\n", " \"blk_avg_tip\": block.average_tip(),\n", " \"pool_length\": txpool.pool_length(),\n", " }\n", " metrics.append(row_metrics)\n", "\n", " # Finally, basefee is updated and a new round starts\n", " basefee = update_basefee(block, basefee)\n", "\n", " return (pd.DataFrame(metrics), user_pool, chain)" ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { "model_id": "", "version_major": 2, "version_minor": 0 }, "text/plain": [ "simulation loop: 0%| | 0/100 [00:00, ?it/s]" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "blocks = 100\n", "demand_scenario = [250 for i in range(blocks)]\n", "\n", "shares_scenario = [{\n", " UserWallet: 1\n", "} for i in range(blocks)]\n", "\n", "(df, user_pool, chain) = simulate(demand_scenario, shares_scenario)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Easy setting: Fixed demand per step, simple wallet rules\n", "\n", "We start with a setting where a constant number of users spawns between every two blocks, and all users send their transactions via the `WalletOracle`.\n", "\n", "Since basefee is too low at the start, 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. Users are now presented with the three options and choose their preferred one.\n", "\n", "By block 25, when the basefee stabilises, the UI switches back to the `posted` price binary option." ] }, { "cell_type": "code", "execution_count": 7, "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", " | block | \n", "basefee | \n", "ui_mode | \n", "users | \n", "decided_txs | \n", "included_txs | \n", "blk_min_premium | \n", "blk_avg_gas_price | \n", "blk_avg_tip | \n", "pool_length | \n", "
---|---|---|---|---|---|---|---|---|---|---|
0 | \n", "0 | \n", "1.000000 | \n", "posted | \n", "250 | \n", "231 | \n", "100 | \n", "1.0 | \n", "2.000000 | \n", "1.000000 | \n", "131 | \n", "
1 | \n", "1 | \n", "1.125000 | \n", "expert | \n", "251 | \n", "208 | \n", "100 | \n", "2.0 | \n", "3.615000 | \n", "2.490000 | \n", "239 | \n", "
2 | \n", "2 | \n", "1.265625 | \n", "expert | \n", "257 | \n", "206 | \n", "100 | \n", "2.0 | \n", "3.725625 | \n", "2.460000 | \n", "345 | \n", "
3 | \n", "3 | \n", "1.423828 | \n", "expert | \n", "246 | \n", "200 | \n", "100 | \n", "2.0 | \n", "3.913828 | \n", "2.490000 | \n", "400 | \n", "
4 | \n", "4 | \n", "1.601807 | \n", "expert | \n", "228 | \n", "191 | \n", "100 | \n", "2.0 | \n", "4.041807 | \n", "2.440000 | \n", "400 | \n", "
5 | \n", "5 | \n", "1.802032 | \n", "expert | \n", "249 | \n", "210 | \n", "100 | \n", "2.0 | \n", "4.322032 | \n", "2.520000 | \n", "400 | \n", "
6 | \n", "6 | \n", "2.027287 | \n", "expert | \n", "263 | \n", "215 | \n", "100 | \n", "2.0 | \n", "4.497287 | \n", "2.470000 | \n", "400 | \n", "
7 | \n", "7 | \n", "2.280697 | \n", "expert | \n", "252 | \n", "189 | \n", "100 | \n", "2.0 | \n", "4.730697 | \n", "2.450000 | \n", "400 | \n", "
8 | \n", "8 | \n", "2.565785 | \n", "expert | \n", "256 | \n", "203 | \n", "100 | \n", "2.0 | \n", "5.035785 | \n", "2.470000 | \n", "400 | \n", "
9 | \n", "9 | \n", "2.886508 | \n", "expert | \n", "236 | \n", "179 | \n", "100 | \n", "2.0 | \n", "5.286508 | \n", "2.400000 | \n", "400 | \n", "
10 | \n", "10 | \n", "3.247321 | \n", "expert | \n", "234 | \n", "166 | \n", "100 | \n", "2.0 | \n", "5.647321 | \n", "2.400000 | \n", "400 | \n", "
11 | \n", "11 | \n", "3.653236 | \n", "expert | \n", "258 | \n", "194 | \n", "100 | \n", "2.0 | \n", "6.053236 | \n", "2.400000 | \n", "400 | \n", "
12 | \n", "12 | \n", "4.109891 | \n", "expert | \n", "237 | \n", "166 | \n", "100 | \n", "2.0 | \n", "6.489891 | \n", "2.380000 | \n", "400 | \n", "
13 | \n", "13 | \n", "4.623627 | \n", "expert | \n", "229 | \n", "158 | \n", "100 | \n", "2.0 | \n", "6.963627 | \n", "2.340000 | \n", "400 | \n", "
14 | \n", "14 | \n", "5.201580 | \n", "expert | \n", "231 | \n", "144 | \n", "100 | \n", "2.0 | \n", "7.581580 | \n", "2.380000 | \n", "400 | \n", "
15 | \n", "15 | \n", "5.851778 | \n", "expert | \n", "254 | \n", "151 | \n", "100 | \n", "2.0 | \n", "8.161778 | \n", "2.310000 | \n", "400 | \n", "
16 | \n", "16 | \n", "6.583250 | \n", "expert | \n", "250 | \n", "144 | \n", "100 | \n", "2.0 | \n", "8.893250 | \n", "2.310000 | \n", "400 | \n", "
17 | \n", "17 | \n", "7.406156 | \n", "expert | \n", "266 | \n", "142 | \n", "100 | \n", "1.0 | \n", "9.652867 | \n", "2.246710 | \n", "400 | \n", "
18 | \n", "18 | \n", "8.331926 | \n", "expert | \n", "256 | \n", "127 | \n", "100 | \n", "1.0 | \n", "10.411926 | \n", "2.080000 | \n", "400 | \n", "
19 | \n", "19 | \n", "9.373417 | \n", "expert | \n", "246 | \n", "106 | \n", "100 | \n", "1.0 | \n", "11.213417 | \n", "1.840000 | \n", "400 | \n", "
20 | \n", "20 | \n", "10.545094 | \n", "expert | \n", "255 | \n", "95 | \n", "100 | \n", "1.0 | \n", "12.275094 | \n", "1.730000 | \n", "395 | \n", "
21 | \n", "21 | \n", "11.863231 | \n", "expert | \n", "232 | \n", "77 | \n", "100 | \n", "1.0 | \n", "13.443231 | \n", "1.580000 | \n", "372 | \n", "
22 | \n", "22 | \n", "13.346134 | \n", "expert | \n", "241 | \n", "71 | \n", "100 | \n", "1.0 | \n", "14.906134 | \n", "1.560000 | \n", "343 | \n", "
23 | \n", "23 | \n", "15.014401 | \n", "expert | \n", "247 | \n", "45 | \n", "61 | \n", "1.0 | \n", "16.407844 | \n", "1.393443 | \n", "327 | \n", "
24 | \n", "24 | \n", "15.427297 | \n", "expert | \n", "251 | \n", "42 | \n", "42 | \n", "1.0 | \n", "17.093964 | \n", "1.666667 | \n", "327 | \n", "
25 | \n", "25 | \n", "15.118751 | \n", "expert | \n", "240 | \n", "43 | \n", "43 | \n", "1.0 | \n", "17.002472 | \n", "1.883721 | \n", "327 | \n", "
26 | \n", "26 | \n", "14.854173 | \n", "posted | \n", "242 | \n", "46 | \n", "46 | \n", "1.0 | \n", "15.854173 | \n", "1.000000 | \n", "327 | \n", "
27 | \n", "27 | \n", "14.705631 | \n", "posted | \n", "273 | \n", "66 | \n", "69 | \n", "1.0 | \n", "15.705631 | \n", "1.000000 | \n", "324 | \n", "
28 | \n", "28 | \n", "15.404149 | \n", "posted | \n", "233 | \n", "42 | \n", "42 | \n", "1.0 | \n", "16.404149 | \n", "1.000000 | \n", "324 | \n", "
29 | \n", "29 | \n", "15.096066 | \n", "posted | \n", "248 | \n", "56 | \n", "56 | \n", "1.0 | \n", "16.096066 | \n", "1.000000 | \n", "324 | \n", "
30 | \n", "30 | \n", "15.322507 | \n", "posted | \n", "262 | \n", "43 | \n", "43 | \n", "1.0 | \n", "16.322507 | \n", "1.000000 | \n", "324 | \n", "