Merge 556231357414c11f7bf693e89ac37b9c0313abfb into a42dc4b4bf6a3aa11bed8735f9647dfd9cadb824

This commit is contained in:
fredericosteixeira 2025-01-05 13:22:31 +00:00 committed by GitHub
commit 07f9b23d40
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 682 additions and 0 deletions

2
.gitignore vendored
View File

@ -12,3 +12,5 @@ simlib/**/target
simlib/**/Cargo.lock
simlib/test.json
*.ignore*
*.png
*/.DS_Store

View File

@ -0,0 +1,5 @@
# Transaction Fee Models
This folder contains the implementation of a tool that compares two different Transaction Fee Models (TFMs) under the same "demand for blockspace".
Please refer to the blog post [not-published-yet](not-published-yet) for more details, and to the notebook `eip-vs-stablefee.ipynb` for usage.

View File

@ -0,0 +1,186 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Transaction Fees on Nomos"
]
},
{
"cell_type": "code",
"execution_count": 1,
"metadata": {},
"outputs": [],
"source": [
"%load_ext autoreload\n",
"%autoreload 2\n",
"\n",
"import plotly.express as px\n",
"import pandas as pd\n",
"pd.options.plotting.backend = \"plotly\"\n",
"from simulation import run_simulation\n",
"from simulation_parameters import SimulationParameters\n",
"from tx_fees_models import PROTOCOL_CONSTANTS"
]
},
{
"cell_type": "code",
"execution_count": 2,
"metadata": {},
"outputs": [],
"source": [
"days = 1"
]
},
{
"cell_type": "code",
"execution_count": 3,
"metadata": {},
"outputs": [],
"source": [
"demand = 5000\n",
"\n",
"std_dev = 0.01/100.\n",
"variable_gas_limits = True\n",
"below_gas_limit = 0.5"
]
},
{
"cell_type": "code",
"execution_count": 4,
"metadata": {},
"outputs": [],
"source": [
"params = SimulationParameters(\n",
" num_blocks=int(days * 24 * 60 * 60 / 12), # number of blocks in \"days\",\n",
" demand_sizes=[demand], # very low, normal, very high demand\n",
" demand_probabilities=[1.], # they must sum to 1\n",
" fee_cap_range=(0., std_dev), # in percentage, mean and std deviation\n",
" max_tip_pct=0, # this only affects the EIP-1559 simulation\n",
" scale_block_size=0.05,\n",
" probability_stop_below_gas_limit=below_gas_limit,\n",
" purge_after=4,\n",
" variable_gas_limits=variable_gas_limits\n",
")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"df_stats_merged, df_chain_stats_merged = run_simulation(params)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"df_stats_merged[[\"price_StableFee\", \"price_EIP\"]].plot(\n",
" title=f'''\n",
"StableFee vs EIP-1559 - {demand} txs -\n",
"{\"variable\" if variable_gas_limits else \"fixed\"} gas/tx -\n",
"{std_dev * 100:.2e}% standard deviation - \n",
"{below_gas_limit * 100:.2f}% chance to stop below gas limit\n",
"''',\n",
")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"df_stats_merged[[\"num_tx_EIP\", \"num_tx_StableFee\"]].plot()"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"df_stats_merged[[\"tot_paid_EIP\", \"tot_paid_StableFee\"]].plot()"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"df_stats_merged[[\"demand_size_EIP\"]].plot.bar()"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"(df_stats_merged[\"num_tx_EIP\"].sub(df_stats_merged[\"num_tx_StableFee\"]) > 0).value_counts()"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"df_chain_stats_merged[[\"tot_gas_used_EIP\", \"tot_gas_used_StableFee\"]].plot()"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"fig = df_chain_stats_merged[\"tot_gas_used_EIP\"].plot.bar()\n",
"fig.add_shape(\n",
" type=\"line\",\n",
" x0=0,\n",
" y0=PROTOCOL_CONSTANTS[\"TARGET_GAS_USED\"],\n",
" x1=len(df_chain_stats_merged),\n",
" y1=PROTOCOL_CONSTANTS[\"TARGET_GAS_USED\"],\n",
" line=dict(color=\"red\", width=2)\n",
")\n",
"\n",
"fig.show()"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {
"kernelspec": {
"display_name": "python-3.12-abm",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.0"
}
},
"nbformat": 4,
"nbformat_minor": 2
}

View File

@ -0,0 +1,117 @@
from typing import List, Dict, Any, Tuple
import numpy as np
np.random.seed(42)
import pandas as pd
import tqdm
from simulation_parameters import SimulationParameters
from tx_fees_models import Block, Blockchain, MemPool, TransactionFeeMechanism, \
EIP1559, StableFee, Transaction, create_demand
def _generate_random_bool(p_true=0.5):
return np.random.choice([True, False], p=(p_true, 1.-p_true), size=1)[0]
def run_simulation(params: SimulationParameters) -> Tuple[pd.DataFrame, pd.DataFrame]:
blockchain:Dict[str,Blockchain] = {
"EIP": Blockchain(),
"StableFee": Blockchain()
}
mempool:Dict[str, MemPool] = {
"EIP": MemPool(),
"StableFee": MemPool()
}
tf_models:dict[str, TransactionFeeMechanism] = {
"EIP": EIP1559(),
"StableFee": StableFee()
}
df_stats:Dict[str, pd.DataFrame] = {
"EIP": pd.DataFrame(
0,
columns=["demand_size", "num_tx", "price", "tot_paid"],
index=range(params.num_blocks)
),
"StableFee": pd.DataFrame(
0,
columns=["demand_size", "num_tx", "price", "tot_paid"],
index=range(params.num_blocks)
)
}
pbar = tqdm.tqdm(total=params.num_blocks)
for b in range(params.num_blocks):
# transactions are created with random values
demand_size:int = np.random.choice(params.demand_sizes, p=params.demand_probabilities, size=1)[0]
demand:List[Transaction] = create_demand(
demand_size,
fee_cap_range=params.fee_cap_range,
max_tip_pct=params.max_tip_pct,
variable_gas=params.variable_gas_limits
)
stop_below_gas_limit:bool = _generate_random_bool(params.probability_stop_below_gas_limit)
scale_block_size = np.random.uniform(
0., params.scale_block_size
)
for chain, pool, tfm, stats in zip(
blockchain.values(), mempool.values(), tf_models.values(), df_stats.values()
):
scaled_demand:List[Transaction] = tfm.scale_demand(demand)
# transactions are added to the mempool
pool.add_txs(scaled_demand)
# transactions are selected from the mempool based on the gas premium
selected_transactions, to_be_purged_transactions = tfm.select_transactions(
pool,
stop_below_gas_limit=stop_below_gas_limit,
scale_block_size=scale_block_size,
purge_after=params.purge_after
)
# selected transactions are added to the blockchain
chain.add_block(Block(selected_transactions))
# the price is updated based on the selected transactions
tfm.update_price(chain)
# base fee is updated for the next round
stats.loc[b, "demand_size"] = demand_size
stats.loc[b, "num_tx"] = len(selected_transactions)
stats.loc[b, "price"] = tfm.get_current_price()
stats.loc[b, "tot_paid"] = tfm.total_paid_fees(selected_transactions)
# clear the mempool
pool.remove_txs(selected_transactions)
pool.remove_txs(to_be_purged_transactions)
pbar.update(1)
pbar.close()
df_stats_merged = pd.concat(
[df_stats[mechanism].add_suffix("_" + mechanism) for mechanism in df_stats.keys()],
axis=1
)
df_chain_stats:Dict[str,pd.DataFrame] = {
"EIP": blockchain["EIP"].compute_stats(),
"StableFee": blockchain["StableFee"].compute_stats()
}
df_chain_stats_merged = pd.concat(
[df_chain_stats[mechanism].add_suffix("_" + mechanism) for mechanism in df_stats.keys()],
axis=1
)
return df_stats_merged, df_chain_stats_merged

View File

@ -0,0 +1,38 @@
from dataclasses import dataclass
from typing import List, Dict, Any, Tuple
@dataclass
class SimulationParameters:
def __init__(
self,
num_blocks: int = 100,
demand_sizes: List[int] = [50, 1000, 2000], # very low, normal, very high demand
demand_probabilities: List[float] = [0.05, 0.9, 0.05],
fee_cap_range:Tuple[float]=(-0.1, 0.1),
max_tip_pct:float=0.1,
scale_block_size: float = 0.2,
probability_stop_below_gas_limit: float = 0.5,
purge_after: int = 4,
variable_gas_limits: bool = True
):
assert len(demand_sizes) == len(demand_probabilities), "demand_sizes and demand_probabilities must have the same length"
assert abs(sum(demand_probabilities) - 1.0) < 1.e-12, "demand_probabilities must sum to 1.0"
assert all([0.0 <= p <= 1.0 for p in demand_probabilities]), "demand_probabilities must be between 0 and 1"
assert 0.0 <= probability_stop_below_gas_limit <= 1.0, "probability_stop_below_gas_limit must be between 0 and 1"
assert 0 < scale_block_size, "scale_block_size must be strictly positive"
assert scale_block_size <= 1.0, "scale_block_size must be less than or equal to 1.0"
assert len(fee_cap_range) == 2, "fee_cap_range must be a tuple of length 2"
assert fee_cap_range[0] <= fee_cap_range[1], "fee_cap_range must be in increasing order"
assert max_tip_pct >= 0.0, "max_tip_pct must be positive"
assert purge_after >= 1, "purge_after must be at least 1"
self.num_blocks = num_blocks
self.demand_sizes = demand_sizes
self.demand_probabilities = demand_probabilities
self.fee_cap_range = fee_cap_range
self.max_tip_pct = max_tip_pct
self.scale_block_size = scale_block_size
self.probability_stop_below_gas_limit = probability_stop_below_gas_limit
self.purge_after = purge_after
self.variable_gas_limits = variable_gas_limits

View File

@ -0,0 +1,334 @@
import numpy as np
import pandas as pd
from typing import List, Tuple, Dict
from dataclasses import dataclass, field
import uuid
import copy
PROTOCOL_CONSTANTS = {
"TARGET_GAS_USED": 12500000.0, # 12.5 million gas
"MAX_GAS_ALLOWED": 25000000.0, # 25 million gas
"INITIAL_BASEFEE": 1.0, # 10^9 wei = 1 Gwei
"MIN_PRICE": 0.5, # 10^8 wei = 0.1 Gwei
"MAX_PRICE": 10.0 # 10^11 wei = 100 Gwei
}
class Transaction:
def __init__(self, gas_used:float, fee_cap:float, tip:float):
self.gas_used = gas_used
self.fee_cap = fee_cap
self.tip = tip # this is ignored in the case of Stable Fee
self.tx_hash = uuid.uuid4().int
@dataclass
class MemPool:
pool: Dict[int, Transaction] = field(default_factory=dict)
def add_tx(self, tx: Transaction):
self.pool[tx.tx_hash] = tx
def add_txs(self, txs: List[Transaction]):
for tx in txs:
self.add_tx(tx)
def remove_tx(self, tx: Transaction):
self.pool.pop(tx.tx_hash)
def remove_txs(self, txs: List[Transaction]):
for tx in txs:
self.remove_tx(tx)
def __len__(self):
return len(self.pool)
@dataclass
class Block():
txs:Dict[int, Transaction]
def add_tx(self, tx: Transaction):
self.txs[tx.tx_hash] = tx
def add_txs(self, txs: List[Transaction]):
for tx in txs:
self.add_tx(tx)
def print_dataframe(self) -> pd.DataFrame:
_df = pd.DataFrame(
[
[tx.gas_used, tx.fee_cap, tx.tip]
for tx in self.txs
],
columns=['gas_used', 'fee_cap', 'tip'],
index=range(len(self.txs))
)
_df.index.name = "tx"
return _df
class Blockchain:
def __init__(self, blocks: List[Block]=None):
self.blocks:List[Block] = []
if blocks:
self.add_blocks(blocks)
def add_block(self, block: Block):
self.blocks.append(block)
def add_blocks(self, blocks: List[Block]):
for block in blocks:
self.add_block(block)
def get_last_block(self):
return self.blocks[-1]
def compute_stats(self) -> pd.DataFrame:
# compute total gas used, total gas premium, total fee cap, average gas premium, average fee cap
_df = pd.DataFrame(
0.,
columns=['tot_gas_used', 'tot_fee_cap', 'tot_tips', 'avg_fee_cap', 'avg_tips', 'gas_target'],
index=range(len(self.blocks))
)
for b, block in enumerate(self.blocks):
num_tx = float(len(block.txs))
tot_gas_used, tot_fee_cap, tot_tips = np.sum(
[
[tx.gas_used, tx.fee_cap, tx.tip]
for tx in block.txs
],
axis=0
)
_df.iloc[b,:] = np.array(
[
tot_gas_used, tot_fee_cap, tot_tips,
tot_fee_cap/num_tx, tot_tips/num_tx, PROTOCOL_CONSTANTS["TARGET_GAS_USED"]
]
)
return _df
def create_demand(
num_txs: int,
fee_cap_range:Tuple[float]=(-0.1, 0.1),
max_tip_pct:float=0.1,
variable_gas:bool=True
) -> List[Transaction]:
# these are levels that need to be scaled by the price/base fee of the specific transaction fee model
fee_caps = 1. + np.random.normal(fee_cap_range[0], fee_cap_range[1], num_txs)
#fee_caps = (1. + np.random.uniform(fee_cap_range[0], fee_cap_range[1], num_txs))
tip = np.random.uniform(0., max_tip_pct, num_txs) # 0.1 is the max gas premium factor
if variable_gas:
return _create_demand_variable_gas(fee_caps, tip)
else:
return _create_demand_const_gas(fee_caps, tip)
def _create_demand_const_gas(fee_caps:np.ndarray, tip:np.ndarray) -> List[Transaction]:
demand: List[Transaction] = []
gas_used = np.mean([ # 73_850 gas limit
21_000 * 0.3, # eth transfer
45_000 * 0.3, # erc20 transfer
50_000 * 0.1, # token approval
200_000 * 0.2, # token swap
150_000 * 0.03, # NFT (ERC721) minting
75_000* 0.03, # NFT transfer
120_000 * 0.03, # NFT (ERC1155) minting
500_000 * 0.01, # smart contract deployment
])
for fc, tp in zip(fee_caps, tip):
tx = Transaction(
gas_used = gas_used,
fee_cap = fc,
tip= tp
)
demand.append(tx)
return demand
def _create_demand_variable_gas(fee_caps:np.ndarray, tip:np.ndarray) -> List[Transaction]:
demand: List[Transaction] = []
gas_used = np.random.choice(
[
21_000, # eth transfer
45_000, # erc20 transfer
50_000, # token approval
200_000, # token swap
150_000, # NFT (ERC721) minting
75_000, # NFT transfer
120_000, # NFT (ERC1155) minting
500_000, # smart contract deployment
],
p=(0.3, 0.3, 0.1, 0.2, 0.03, 0.03, 0.03, 0.01),
size=len(fee_caps)
)
for gu, fc, tp in zip(gas_used, fee_caps, tip):
tx = Transaction(
gas_used = gu,
fee_cap = fc,
tip = tp
)
demand.append(tx)
return demand
class TransactionFeeMechanism:
def __init__(self):
self.price:List[float] = []
self.price.append(PROTOCOL_CONSTANTS["INITIAL_BASEFEE"])
def update_price(self, blockchain:Blockchain):
raise NotImplementedError
def get_current_price(self) -> float:
return self.price[-1]
def scale_demand(self, demand:List[Transaction]) -> List[Transaction]:
cur_price:float = self.get_current_price()
scaled_demand = copy.deepcopy(demand)
for tx in scaled_demand:
tx.fee_cap *= cur_price
tx.tip *= cur_price
return scaled_demand
def _select_from_sorted_txs(
self,
sorted_txs:List[Transaction],
stop_below_gas_limit:bool=False,
scale_block_size:float=1.0, purge_after:int=4
) -> Tuple[List[Transaction], List[Transaction]]:
# select transactions so that the sum of gas used is less than the block gas limit
selected_txs_idx = 0
to_be_purged_txs_idx = 0
gas_used = 0
# introduce some randomness in the selection in case there are too many transactions
# this is to simulate the fact that miners may not always select the most profitable transactions
# this increases or decreases the number of transactions selected based on the stop_below_gas_limit flag,
# which is also randomly selected
fac = 1.0 + (1. - 2.*stop_below_gas_limit) * scale_block_size
for tx in sorted_txs:
if gas_used + tx.gas_used < fac * PROTOCOL_CONSTANTS["TARGET_GAS_USED"]:
selected_txs_idx += 1
if gas_used + tx.gas_used < purge_after * PROTOCOL_CONSTANTS["MAX_GAS_ALLOWED"]: # enough space for X full blocks
to_be_purged_txs_idx += 1
else:
break
gas_used += tx.gas_used
return sorted_txs[:selected_txs_idx], sorted_txs[to_be_purged_txs_idx:]
def select_transactions(
self, mempool: MemPool, stop_below_gas_limit:bool=False, scale_block_size:float=1.0, purge_after:int=4
) -> Tuple[List[Transaction], List[Transaction]]:
raise NotImplementedError
def total_paid_fees(self, txs: List[Transaction]) -> float:
raise NotImplementedError
class EIP1559(TransactionFeeMechanism):
def __init__(self):
self.base_factor:float = 1./8.
self.base_fee:List[float] = []
self.base_fee.append(PROTOCOL_CONSTANTS["INITIAL_BASEFEE"])
super().__init__()
def update_price(self, blockchain:Blockchain) -> float:
base_fee:float = self.base_fee[-1]
last_txs:List[Transaction] = blockchain.get_last_block().txs
gas_used:float = sum([tx.gas_used for tx in last_txs])
delta:float = (gas_used - PROTOCOL_CONSTANTS["TARGET_GAS_USED"])/PROTOCOL_CONSTANTS["TARGET_GAS_USED"]
self.base_fee.append(
base_fee * np.exp(delta * self.base_factor) # (1. + delta * self.base_factor)
)
sum_price:float = sum([min(tx.fee_cap, base_fee+tx.tip) for tx in last_txs])
self.price.append(
np.clip(
sum_price/float(len(last_txs)),
a_min=PROTOCOL_CONSTANTS["MIN_PRICE"],
a_max=PROTOCOL_CONSTANTS["MAX_PRICE"]
)
)
def select_transactions(
self, mempool: MemPool, stop_below_gas_limit:bool=False, scale_block_size:float=1.0, purge_after:int=4
) -> Tuple[List[Transaction], List[Transaction]]:
base_fee:float = self.base_fee[-1]
# Sort transactions by fee cap
sorted_txs:List[Transaction] = sorted(
mempool.pool.values(),
key=lambda tx: min(tx.fee_cap, base_fee+tx.tip) * tx.gas_used,
reverse=True
)
return self._select_from_sorted_txs(
sorted_txs,
stop_below_gas_limit=stop_below_gas_limit,
scale_block_size=scale_block_size,
purge_after=purge_after
)
def total_paid_fees(self, txs: List[Transaction]) -> float:
base_fee:float = self.base_fee[-1]
return sum([min(tx.fee_cap, base_fee+tx.tip) * tx.gas_used for tx in txs])
class StableFee(TransactionFeeMechanism):
def __init__(self):
super().__init__()
def update_price(self, blockchain:Blockchain):
last_txs:List[Transaction] = blockchain.get_last_block().txs
new_price:float = np.min([tx.fee_cap for tx in last_txs])
self.price.append(
np.clip(
new_price,
a_min=PROTOCOL_CONSTANTS["MIN_PRICE"],
a_max=PROTOCOL_CONSTANTS["MAX_PRICE"]
)
)
def select_transactions(
self, mempool: MemPool, stop_below_gas_limit:bool=False, scale_block_size:float=1.0, purge_after:int=4
) -> Tuple[List[Transaction], List[Transaction]]:
# Sort transactions by fee cap
sorted_txs = sorted(
mempool.pool.values(),
key=lambda tx: tx.fee_cap * tx.gas_used,
reverse=True
)
return self._select_from_sorted_txs(
sorted_txs,
stop_below_gas_limit=stop_below_gas_limit,
scale_block_size=scale_block_size,
purge_after=purge_after
)
def total_paid_fees(self, txs: List[Transaction]) -> float:
price:float = self.get_current_price()
return sum([price * tx.gas_used for tx in txs])