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
class OptimisticUser(User1559):
def expected_time(self, params):
return 1
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, params):
return 1
def decide_parameters(self, params):
if params["min_premium"] is None:
min_premium = 1 * (10 ** 9)
else:
min_premium = params["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}"
We now allow users to cancel their transactions. We discussed in the previous notebook's bonus section the concept of ex post individual rationality. Suppose I estimate that should my transaction be included within 5 blocks, my payoff will be positive. In this case, I decide to join the pool and send my transaction. Fierce competition during transitionary periods however might prevent me from being included when I thought I would be. Without the ability to cancel my transaction, I must stick around in the transaction pool and could net a negative payoff once I am included.
We add here the possibility for users to cancel their transaction. Cancelling is not completely trivial in the current, first-price auction paradigm (see Etherscan's helpful guide for an overview). To cancel transaction A, the trick is to send another transaction B with the same nonce (your address's "transaction counter") with a slightly higher fee. Miners with access to both A and B in their pool will strictly prefer B and cannot include both.
With EIP 1559, we'll assume users send a new transaction with a slightly higher premium. We also assumed that miners keep around an extensible list of pending transactions, even those rendered invalid from a high basefee, in case the basefee decreases. So we'll assume that as soon as a user sends a replacement transaction to cancel its original transaction, the original transaction disappears from the mempool. The costs to users vary according to the outcome:
Of course, $g$ and $g'$ both depend on the current basefee, which complicates the matter. To simplify here, we assume that as soon as the current value $v - w . c$ of the user becomes negative, the user cancels their transaction.
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 range(len(demand_scenario)):
# `params` are the "environment" of the simulation
params = {
"basefee": basefee,
"current_block": t,
"min_premium": min_premium,
"cancel_cost": 2 * (10 ** 9), # in wei/gas
}
#########
## ADDED
##
# We ask whether current users want to cancel their transactions waiting in the pool
cancelled_txs = user_pool.cancel_transactions(txpool, params)
##
# Cancel transactions in the pool, adds new empty transactions with higher fee
txpool.cancel_txs(cancelled_txs, params["cancel_cost"])
##
#########
# We return some demand which on expectation yields 2000 new users per round
users = spawn_poisson_heterogeneous_demand(t, demand_scenario[t], shares_scenario[t])
# Add new users to the pool
# 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, params)
# 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(params)
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
row_metrics = {
"block": t,
"basefee": basefee / (10 ** 9),
"users": len(users),
"strategic": len([user for user in users if type(user) is StrategicUser]),
"nonstategic": len([user for user in users if type(user) is OptimisticUser]),
"decided_txs": len(decided_txs),
"included_txs": len(selected_txs),
"cancelled_txs": len(cancelled_txs),
"blk_min_premium": block.min_premium() / (10 ** 9), # to Gwei
"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 = 200
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)
df
block | basefee | users | strategic | nonstategic | decided_txs | included_txs | cancelled_txs | blk_min_premium | pool_length | |
---|---|---|---|---|---|---|---|---|---|---|
0 | 0 | 1.000000 | 2498 | 1249 | 1249 | 2170 | 952 | 0 | 1.1 | 1218 |
1 | 1 | 1.124900 | 2595 | 1297 | 1298 | 2242 | 952 | 0 | 1.2 | 2508 |
2 | 2 | 1.265400 | 2409 | 1204 | 1205 | 2086 | 952 | 37 | 1.3 | 3605 |
3 | 3 | 1.423448 | 2534 | 1267 | 1267 | 2122 | 952 | 57 | 1.4 | 4718 |
4 | 4 | 1.601237 | 2618 | 1309 | 1309 | 2190 | 952 | 84 | 1.5 | 5872 |
... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
195 | 195 | 14.561289 | 2520 | 1260 | 1260 | 536 | 536 | 2 | 1.0 | 456 |
196 | 196 | 14.789901 | 2461 | 1230 | 1231 | 429 | 429 | 0 | 1.0 | 456 |
197 | 197 | 14.606691 | 2483 | 1241 | 1242 | 490 | 490 | 3 | 1.0 | 453 |
198 | 198 | 14.659640 | 2468 | 1234 | 1234 | 458 | 458 | 3 | 1.0 | 450 |
199 | 199 | 14.589641 | 2570 | 1285 | 1285 | 499 | 499 | 4 | 1.0 | 446 |
200 rows × 10 columns
We observe a similar plot of basefee.
df.plot("block", ["basefee", "blk_min_premium"])
<AxesSubplot:xlabel='block'>
But we can now see the txpool slowly emptying, as users cancel their transactions.
df.plot("block", ["pool_length", "users"])
<AxesSubplot:xlabel='block'>
Cancelled transactions are at their peak early in the simulation. During the basefee's transitionary period, many hopeful users find it profitable to send transactions in, believing they will be included quickly. But as basefee rises, they are priced out, and remain in the pool. Those with higher time-sensitivity see their current value decrease faster, and cancel early.
df.plot("block", ["users", "decided_txs", "included_txs", "cancelled_txs"])
<AxesSubplot:xlabel='block'>