diff --git a/.gitignore b/.gitignore index 7712c5f..e388b87 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ simlib/**/target simlib/**/Cargo.lock simlib/test.json *.ignore* +*.png +*/.DS_Store diff --git a/transaction_fee_models/README.md b/transaction_fee_models/README.md new file mode 100644 index 0000000..0af048b --- /dev/null +++ b/transaction_fee_models/README.md @@ -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. diff --git a/transaction_fee_models/eip-vs-stablefee.ipynb b/transaction_fee_models/eip-vs-stablefee.ipynb new file mode 100644 index 0000000..fe8e5a5 --- /dev/null +++ b/transaction_fee_models/eip-vs-stablefee.ipynb @@ -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 +} diff --git a/transaction_fee_models/simulation.py b/transaction_fee_models/simulation.py new file mode 100644 index 0000000..8686e7c --- /dev/null +++ b/transaction_fee_models/simulation.py @@ -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 diff --git a/transaction_fee_models/simulation_parameters.py b/transaction_fee_models/simulation_parameters.py new file mode 100644 index 0000000..b02a789 --- /dev/null +++ b/transaction_fee_models/simulation_parameters.py @@ -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 \ No newline at end of file diff --git a/transaction_fee_models/tx_fees_models.py b/transaction_fee_models/tx_fees_models.py new file mode 100644 index 0000000..69733e1 --- /dev/null +++ b/transaction_fee_models/tx_fees_models.py @@ -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]) \ No newline at end of file