{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Strategic users in EIP 1559\n", "\n", "###### September 2020, [@barnabemonnot](https://twitter.com/barnabemonnot)\n", "###### [Robust Incentives Group](https://github.com/ethereum/rig), Ethereum Foundation\n", "\n", "---\n", "\n", "In our [previous notebook](https://github.com/barnabemonnot/abm1559/blob/master/notebooks/stationary1559.ipynb), 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.\n", "\n", "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.\n", "\n", "Firstly, let's load up a few classes from our [`abm1559`](https://github.com/barnabemonnot/abm1559) library." ] }, { "cell_type": "code", "execution_count": 1, "metadata": {}, "outputs": [], "source": [ "import os, sys\n", "sys.path.insert(1, os.path.realpath(os.path.pardir))\n", "# You may remove the two lines above if you have installed abm1559 from pypi\n", "\n", "from abm1559.utils import constants\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", "import pandas as pd\n", "import numpy as np" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Introducing strategic users\n", "\n", "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.\n", "\n", "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:\n", "\n", "$$ \\texttt{payoff} = \\texttt{value} - \\texttt{cost from waiting} - \\texttt{transaction fee} $$\n", "\n", "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 1 block, as we have done with the `OptimisticUser` in the previous notebook." ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [], "source": [ "class OptimisticUser(User1559):\n", " def expected_time(self, params):\n", " return 1" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "`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!\n", "\n", "We'll subclass our `User1559` again to define the strategic user." ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [], "source": [ "class StrategicUser(User1559):\n", " \"\"\"\n", " A strategic affine user sending 1559 transactions.\n", " \n", " - Expects to be included in the next block\n", " - Prefers not to participate if its expected payoff is negative\n", " - Strategic gas_premium\n", " \"\"\"\n", " \n", " epsilon = 0.1 # how much the user overbids by\n", "\n", " def expected_time(self, params):\n", " return 1\n", "\n", " def decide_parameters(self, params):\n", " if params[\"min_premium\"] is None:\n", " min_premium = 1 * (10 ** 9)\n", " else:\n", " min_premium = params[\"min_premium\"]\n", "\n", " gas_premium = min_premium + self.epsilon * (10 ** 9)\n", " max_fee = self.value\n", "\n", " return {\n", " \"max_fee\": max_fee, # in wei\n", " \"gas_premium\": gas_premium, # in wei\n", " \"start_block\": self.wakeup_block,\n", " }\n", "\n", " def export(self):\n", " return {\n", " **super().export(),\n", " \"user_type\": \"strategic_user_1559\",\n", " }\n", "\n", " def __str__(self):\n", " return f\"1559 strategic affine user with value {self.value} and cost {self.cost_per_unit}\"" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "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.\n", "\n", "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_.\n", "\n", "$$ \\texttt{tip} = \\min(\\texttt{fee cap} - \\texttt{basefee}, \\texttt{gas premium}) $$\n", "\n", "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.\n", "\n", "### Mixed simulations\n", "\n", "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:\n", "\n", "1. We sample from a Poisson distribution to obtain the number of new users spawning between two blocks.\n", "2. We spawn optimistic and strategic users according to the shares specified by `shares_scenario`.\n", "3. Each user decides whether to transact or not (`decide_transactions`).\n", "4. Transactions are added to the transaction pool.\n", "5. The miner decides on a set of transactions to include, ranking transactions according to their tips, with higher tips at the top of the list.\n", "6. A block is created and added to the chain.\n", "7. Basefee is updated.\n", "8. Repeat." ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [], "source": [ "def simulate(demand_scenario, shares_scenario):\n", " # Instantiate a couple of things\n", " txpool = TxPool()\n", " basefee = constants[\"INITIAL_BASEFEE\"]\n", " chain = Chain()\n", " metrics = []\n", " user_pool = UserPool()\n", " min_premium = 1 * (10 ** 9)\n", "\n", " for t in range(len(demand_scenario)):\n", " \n", " # `params` are the \"environment\" of the simulation\n", " params = {\n", " \"basefee\": basefee,\n", " \"current_block\": t,\n", " \"min_premium\": min_premium,\n", " }\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])\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, params)\n", "\n", " # New transactions are added to the transaction pool\n", " txpool.add_txs(decided_txs)\n", "\n", " # The best valid transactions are taken out of the pool for inclusion\n", " selected_txs = txpool.select_transactions(params)\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", " # Record the min premium in the block\n", " min_premium = block.min_premium()\n", " \n", " # The block is added to the chain\n", " chain.add_block(block)\n", " \n", " # A couple of metrics we will use to monitor the simulation\n", " pool_strat_users = len(\n", " [tx for tx in txpool.txs.values() if type(user_pool.users[tx.sender]) == StrategicUser])\n", " pool_nonstrat_users = len(\n", " [tx for tx in txpool.txs.values() if type(user_pool.users[tx.sender]) == OptimisticUser])\n", "\n", " row_metrics = {\n", " \"block\": t,\n", " \"basefee\": basefee / (10 ** 9),\n", " \"users\": len(users),\n", " \"strategic\": len([user for user in users if type(user) == StrategicUser]),\n", " \"nonstategic\": len([user for user in users if type(user) == OptimisticUser]),\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", " \"pool_strat_users\": pool_strat_users,\n", " \"pool_nonstrat_users\": pool_nonstrat_users,\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": "markdown", "metadata": {}, "source": [ "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." ] }, { "cell_type": "code", "execution_count": 5, "metadata": {}, "outputs": [], "source": [ "blocks = 100\n", "demand_scenario = [2500 for i in range(blocks)]\n", "\n", "strategic_share = 0.5\n", "shares_scenario = [{\n", " StrategicUser: strategic_share,\n", " OptimisticUser: 1 - strategic_share,\n", "} for i in range(blocks)]\n", "\n", "(df, user_pool, chain) = simulate(demand_scenario, shares_scenario)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "What does `df` reveal?" ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", " | block | \n", "basefee | \n", "users | \n", "strategic | \n", "nonstategic | \n", "decided_txs | \n", "included_txs | \n", "blk_min_premium | \n", "blk_avg_gas_price | \n", "blk_avg_tip | \n", "pool_length | \n", "pool_strat_users | \n", "pool_nonstrat_users | \n", "
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | \n", "0 | \n", "1.000000 | \n", "2542 | \n", "1271 | \n", "1271 | \n", "2230 | \n", "952 | \n", "1.1 | \n", "2.100000 | \n", "1.100000 | \n", "1278 | \n", "162 | \n", "1116 | \n", "
1 | \n", "1 | \n", "1.124900 | \n", "2484 | \n", "1242 | \n", "1242 | \n", "2153 | \n", "952 | \n", "1.2 | \n", "2.324900 | \n", "1.200000 | \n", "2479 | \n", "278 | \n", "2201 | \n", "
2 | \n", "2 | \n", "1.265400 | \n", "2519 | \n", "1259 | \n", "1260 | \n", "2152 | \n", "952 | \n", "1.3 | \n", "2.565400 | \n", "1.300000 | \n", "3679 | \n", "394 | \n", "3285 | \n", "
3 | \n", "3 | \n", "1.423448 | \n", "2617 | \n", "1308 | \n", "1309 | \n", "2198 | \n", "952 | \n", "1.4 | \n", "2.823448 | \n", "1.400000 | \n", "4925 | \n", "529 | \n", "4396 | \n", "
4 | \n", "4 | \n", "1.601237 | \n", "2494 | \n", "1247 | \n", "1247 | \n", "2079 | \n", "952 | \n", "1.5 | \n", "3.101237 | \n", "1.500000 | \n", "6052 | \n", "608 | \n", "5444 | \n", "
... | \n", "... | \n", "... | \n", "... | \n", "... | \n", "... | \n", "... | \n", "... | \n", "... | \n", "... | \n", "... | \n", "... | \n", "... | \n", "... | \n", "
95 | \n", "95 | \n", "14.642903 | \n", "2486 | \n", "1243 | \n", "1243 | \n", "457 | \n", "457 | \n", "1.0 | \n", "15.693669 | \n", "1.050766 | \n", "10747 | \n", "123 | \n", "10624 | \n", "
96 | \n", "96 | \n", "14.569140 | \n", "2479 | \n", "1239 | \n", "1240 | \n", "474 | \n", "474 | \n", "1.0 | \n", "15.619773 | \n", "1.050633 | \n", "10747 | \n", "123 | \n", "10624 | \n", "
97 | \n", "97 | \n", "14.560763 | \n", "2452 | \n", "1226 | \n", "1226 | \n", "455 | \n", "455 | \n", "1.0 | \n", "15.608894 | \n", "1.048132 | \n", "10747 | \n", "123 | \n", "10624 | \n", "
98 | \n", "98 | \n", "14.479768 | \n", "2426 | \n", "1213 | \n", "1213 | \n", "494 | \n", "494 | \n", "1.0 | \n", "15.527744 | \n", "1.047976 | \n", "10747 | \n", "123 | \n", "10624 | \n", "
99 | \n", "99 | \n", "14.547461 | \n", "2474 | \n", "1237 | \n", "1237 | \n", "506 | \n", "506 | \n", "1.0 | \n", "15.596868 | \n", "1.049407 | \n", "10747 | \n", "123 | \n", "10624 | \n", "
100 rows × 13 columns
\n", "\n", " | block | \n", "basefee | \n", "users | \n", "strategic | \n", "nonstategic | \n", "decided_txs | \n", "included_txs | \n", "blk_min_premium | \n", "blk_avg_gas_price | \n", "blk_avg_tip | \n", "pool_length | \n", "pool_strat_users | \n", "pool_nonstrat_users | \n", "
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
24 | \n", "24 | \n", "16.855204 | \n", "2520 | \n", "1260 | \n", "1260 | \n", "213 | \n", "952 | \n", "1.0 | \n", "17.867178 | \n", "1.011975 | \n", "15512 | \n", "123 | \n", "15389 | \n", "
25 | \n", "25 | \n", "18.960418 | \n", "2472 | \n", "1236 | \n", "1236 | \n", "0 | \n", "661 | \n", "1.0 | \n", "19.477388 | \n", "0.516969 | \n", "14851 | \n", "123 | \n", "14728 | \n", "
26 | \n", "26 | \n", "19.880236 | \n", "2452 | \n", "1226 | \n", "1226 | \n", "0 | \n", "0 | \n", "0.0 | \n", "0.000000 | \n", "0.000000 | \n", "14851 | \n", "123 | \n", "14728 | \n", "
27 | \n", "27 | \n", "17.395206 | \n", "2516 | \n", "1258 | \n", "1258 | \n", "214 | \n", "952 | \n", "1.0 | \n", "18.144788 | \n", "0.749581 | \n", "14113 | \n", "253 | \n", "13860 | \n", "