diff --git a/CHANGELOG.md b/CHANGELOG.md index 03298e298c..cf105b8d62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ * [#1664](https://github.com/crypto-org-chain/cronos/pull/1664) Update cometbft to 0.38.13. * [#1660](https://github.com/crypto-org-chain/cronos/pull/1660) Support async check tx. +* [#1667](https://github.com/crypto-org-chain/cronos/pull/1667) Add testnet benchmark command. *Oct 24, 2024* diff --git a/testground/benchmark/benchmark/cosmostx.py b/testground/benchmark/benchmark/cosmostx.py index bdd47734b7..a449223588 100644 --- a/testground/benchmark/benchmark/cosmostx.py +++ b/testground/benchmark/benchmark/cosmostx.py @@ -78,5 +78,23 @@ class TxRaw(ProtoEntity): class MsgEthereumTx(ProtoEntity): + MSG_URL = "/ethermint.evm.v1.MsgEthereumTx" + + data = Field(ProtoAny, 1) + deprecated_hash = Field("string", 3) from_ = Field("bytes", 5) raw = Field("bytes", 6) + + +class LegacyTx(ProtoEntity): + MSG_URL = "/ethermint.evm.v1.LegacyTx" + + nonce = Field("uint64", 1) + gas_price = Field("string", 2) + gas = Field("uint64", 3) + to = Field("string", 4) + value = Field("string", 5) + data = Field("bytes", 6) + v = Field("bytes", 7) + r = Field("bytes", 8) + s = Field("bytes", 9) diff --git a/testground/benchmark/benchmark/stats.py b/testground/benchmark/benchmark/stats.py index c5a74c8fa9..f59d4c91a9 100644 --- a/testground/benchmark/benchmark/stats.py +++ b/testground/benchmark/benchmark/stats.py @@ -1,6 +1,6 @@ from datetime import datetime -from .utils import block, block_eth, block_height +from .utils import LOCAL_JSON_RPC, LOCAL_RPC, block, block_eth, block_height # the tps calculation use the average of the last 10 blocks TPS_WINDOW = 5 @@ -19,33 +19,41 @@ def calculate_tps(blocks): return txs / time_diff -def get_block_info_cosmos(height): - blk = block(height) +def get_block_info_cosmos(height, rpc): + blk = block(height, rpc=rpc) timestamp = datetime.fromisoformat(blk["result"]["block"]["header"]["time"]) txs = len(blk["result"]["block"]["data"]["txs"]) return timestamp, txs -def get_block_info_eth(height): - blk = block_eth(height) +def get_block_info_eth(height, json_rpc): + blk = block_eth(height, json_rpc=json_rpc) timestamp = datetime.fromtimestamp(int(blk["timestamp"], 0)) txs = len(blk["transactions"]) return timestamp, txs -def dump_block_stats(fp, eth=True): +def dump_block_stats( + fp, + eth=True, + json_rpc=LOCAL_JSON_RPC, + rpc=LOCAL_RPC, + start: int = 2, + end: int = None, +): """ dump block stats using web3 json-rpc, which splits batch tx """ tps_list = [] - current = block_height() + if end is None: + end = block_height(rpc) blocks = [] # skip block 1 whose timestamp is not accurate - for i in range(2, current + 1): + for i in range(start, end + 1): if eth: - timestamp, txs = get_block_info_eth(i) + timestamp, txs = get_block_info_eth(i, json_rpc) else: - timestamp, txs = get_block_info_cosmos(i) + timestamp, txs = get_block_info_cosmos(i, rpc) blocks.append((txs, timestamp)) tps = calculate_tps(blocks[-TPS_WINDOW:]) tps_list.append(tps) diff --git a/testground/benchmark/benchmark/testnet.py b/testground/benchmark/benchmark/testnet.py new file mode 100644 index 0000000000..d71ebcb4a5 --- /dev/null +++ b/testground/benchmark/benchmark/testnet.py @@ -0,0 +1,143 @@ +import asyncio +import json +import sys +import time +from pathlib import Path + +import click +import requests +import web3 +from hexbytes import HexBytes + +from .stats import dump_block_stats +from .transaction import EthTx, build_cosmos_tx, gen, json_rpc_send_body, send +from .utils import block_height, gen_account, split_batch + +# arbitrarily picked for testnet, to not conflict with devnet benchmark accounts. +GLOBAL_SEQ = 999 +GAS_PRICE = 5050000000000 +CHAIN_ID = 338 +TESTNET_JSONRPC = "https://evm-t3.cronos.org" +TESTNET_RPC = "https://rpc-t3.cronos.org" +TESTNET_EVM_DENOM = "basetcro" + + +@click.group() +def cli(): + pass + + +@cli.command() +@click.option("--json-rpc", default=TESTNET_JSONRPC) +@click.option("--rpc", default=TESTNET_RPC) +@click.option("--batch-size", default=200) +@click.argument("start", type=int) +@click.argument("end", type=int) +def fund(json_rpc, rpc, batch_size, start, end): + w3 = web3.Web3(web3.HTTPProvider(json_rpc)) + fund_account = gen_account(GLOBAL_SEQ, 0) + fund_address = HexBytes(fund_account.address) + nonce = w3.eth.get_transaction_count(fund_account.address) + + batches = split_batch(end - start + 1, batch_size) + for begin, end in batches: + begin += start + end += start + txs = [] + for i in range(begin, end): + tx = { + "to": gen_account(GLOBAL_SEQ, i).address, + "value": 10 * 10**18, + "nonce": nonce, + "gas": 21000, + "gasPrice": GAS_PRICE, + "chainId": CHAIN_ID, + } + txs.append( + EthTx( + tx, fund_account.sign_transaction(tx).rawTransaction, fund_address + ) + ) + nonce += 1 + raw = build_cosmos_tx(*txs, msg_version="1.3", evm_denom=TESTNET_EVM_DENOM) + rsp = requests.post( + rpc, json=json_rpc_send_body(raw, method="broadcast_tx_sync") + ).json() + if rsp["result"]["code"] != 0: + print(rsp["result"]["log"]) + break + + # wait for nonce to change + while True: + if w3.eth.get_transaction_count(fund_account.address) >= nonce: + break + time.sleep(1) + + print("sent", begin, end) + + +@cli.command() +@click.option("--json-rpc", default=TESTNET_JSONRPC) +@click.argument("start", type=int) +@click.argument("end", type=int) +def check(json_rpc, start, end): + w3 = web3.Web3(web3.HTTPProvider(json_rpc)) + for i in range(start, end + 1): + addr = gen_account(GLOBAL_SEQ, i).address + nonce = w3.eth.get_transaction_count(addr) + balance = int(w3.eth.get_balance(addr)) + print(i, addr, nonce, balance) + + +@cli.command() +@click.argument("start", type=int) +@click.argument("end", type=int) +@click.option("--num-txs", default=1) +@click.option("--nonce", default=0) +@click.option("--batch-size", default=1) +@click.option("--tx-type", default="simple-transfer") +@click.option("--msg-version", default="1.3") +def gen_txs(start, end, num_txs, nonce, batch_size, tx_type, msg_version): + num_accounts = end - start + 1 + txs = gen( + GLOBAL_SEQ, + num_accounts, + num_txs, + tx_type, + batch_size, + start_account=start, + nonce=nonce, + msg_version=msg_version, + tx_options={"gas_price": GAS_PRICE, "chain_id": CHAIN_ID}, + evm_denom=TESTNET_EVM_DENOM, + ) + json.dump(txs, sys.stdout) + + +@cli.command() +@click.argument("path", type=str) +@click.option("--rpc", default=TESTNET_RPC) +@click.option("--sync/--async", default=False) +def send_txs(path, rpc, sync): + txs = json.loads(Path(path).read_text()) + asyncio.run(send(txs, rpc, sync)) + + +@cli.command() +@click.option("--json-rpc", default=TESTNET_JSONRPC) +@click.option("--rpc", default=TESTNET_RPC) +@click.option("--count", default=30) +def stats(json_rpc, rpc, count): + current = block_height(rpc) + dump_block_stats( + sys.stdout, + eth=True, + rpc=rpc, + json_rpc=json_rpc, + start=current - count, + end=current, + ) + + +if __name__ == "__main__": + cli() diff --git a/testground/benchmark/benchmark/transaction.py b/testground/benchmark/benchmark/transaction.py index 5ef86bedfc..5a5794a1cb 100644 --- a/testground/benchmark/benchmark/transaction.py +++ b/testground/benchmark/benchmark/transaction.py @@ -3,6 +3,7 @@ import itertools import multiprocessing import os +import sys from collections import namedtuple from pathlib import Path @@ -10,6 +11,7 @@ import backoff import eth_abi import ujson +from eth_account._utils.legacy_transactions import Transaction from hexbytes import HexBytes from . import cosmostx @@ -21,19 +23,36 @@ CONNECTION_POOL_SIZE = 1024 TXS_DIR = "txs" +Job = namedtuple( + "Job", + [ + "chunk", + "global_seq", + "num_txs", + "tx_type", + "create_tx", + "batch", + "nonce", + "msg_version", + "tx_options", + "evm_denom", + ], +) +EthTx = namedtuple("EthTx", ["tx", "raw", "sender"]) -def simple_transfer_tx(sender: str, nonce: int): + +def simple_transfer_tx(sender: str, nonce: int, options: dict): return { "to": sender, "value": 1, "nonce": nonce, "gas": 21000, - "gasPrice": GAS_PRICE, - "chainId": CHAIN_ID, + "gasPrice": options.get("gas_price", GAS_PRICE), + "chainId": options.get("chain_id", CHAIN_ID), } -def erc20_transfer_tx(sender: str, nonce: int): +def erc20_transfer_tx(sender: str, nonce: int, options: dict): # data is erc20 transfer function call data = "0xa9059cbb" + eth_abi.encode(["address", "uint256"], [sender, 1]).hex() return { @@ -41,8 +60,8 @@ def erc20_transfer_tx(sender: str, nonce: int): "value": 0, "nonce": nonce, "gas": 51630, - "gasPrice": GAS_PRICE, - "chainId": CHAIN_ID, + "gasPrice": options.get("gas_price", GAS_PRICE), + "chainId": options.get("chain_id", CHAIN_ID), "data": data, } @@ -53,11 +72,48 @@ def erc20_transfer_tx(sender: str, nonce: int): } -Job = namedtuple( - "Job", - ["chunk", "global_seq", "num_accounts", "num_txs", "tx_type", "create_tx", "batch"], -) -EthTx = namedtuple("EthTx", ["tx", "raw", "sender"]) +def build_evm_msg_1_3(tx: EthTx): + """ + build cronos v1.3 version of MsgEthereumTx + """ + txn = Transaction.from_bytes(tx.raw) + return cosmostx.build_any( + cosmostx.MsgEthereumTx.MSG_URL, + cosmostx.MsgEthereumTx( + data=cosmostx.build_any( + cosmostx.LegacyTx.MSG_URL, + cosmostx.LegacyTx( + nonce=txn.nonce, + gas_price=str(txn.gasPrice), + gas=txn.gas, + to=txn.to.hex(), + value=str(txn.value), + data=txn.data, + v=txn.v.to_bytes(32, byteorder="big"), + r=txn.r.to_bytes(32, byteorder="big"), + s=txn.s.to_bytes(32, byteorder="big"), + ), + ), + deprecated_hash=txn.hash().hex(), + from_=tx.sender, + ), + ) + + +def build_evm_msg_1_4(tx: EthTx): + return cosmostx.build_any( + cosmostx.MsgEthereumTx.MSG_URL, + cosmostx.MsgEthereumTx( + from_=tx.sender, + raw=tx.raw, + ), + ) + + +MSG_VERSIONS = { + "1.3": build_evm_msg_1_3, + "1.4": build_evm_msg_1_4, +} def _do_job(job: Job): @@ -67,28 +123,55 @@ def _do_job(job: Job): for acct in accounts: txs = [] for i in range(job.num_txs): - tx = job.create_tx(acct.address, i) + tx = job.create_tx(acct.address, job.nonce + i, job.tx_options) raw = acct.sign_transaction(tx).rawTransaction txs.append(EthTx(tx, raw, HexBytes(acct.address))) total += 1 if total % 1000 == 0: - print("generated", total, "txs for node", job.global_seq) + print( + "generated", total, "txs for node", job.global_seq, file=sys.stderr + ) # to keep it simple, only build batch inside the account txs = [ - build_cosmos_tx(*txs[start:end]) + build_cosmos_tx( + *txs[start:end], msg_version=job.msg_version, evm_denom=job.evm_denom + ) for start, end in split_batch(len(txs), job.batch) ] acct_txs.append(txs) return acct_txs -def gen(global_seq, num_accounts, num_txs, tx_type: str, batch: int) -> [str]: +def gen( + global_seq, + num_accounts, + num_txs, + tx_type: str, + batch: int, + nonce: int = 0, + start_account: int = 0, + msg_version: str = "1.4", + tx_options: dict = None, + evm_denom: str = DEFAULT_DENOM, +) -> [str]: + tx_options = tx_options or {} chunks = split(num_accounts, os.cpu_count()) create_tx = TX_TYPES[tx_type] jobs = [ - Job(chunk, global_seq, num_accounts, num_txs, tx_type, create_tx, batch) - for chunk in chunks + Job( + (start + start_account, end + start_account), + global_seq, + num_txs, + tx_type, + create_tx, + batch, + nonce, + msg_version, + tx_options, + evm_denom, + ) + for start, end in chunks ] with multiprocessing.Pool() as pool: @@ -119,20 +202,12 @@ def load(datadir: Path, global_seq: int) -> [str]: return ujson.load(f) -def build_cosmos_tx(*txs: EthTx) -> str: +def build_cosmos_tx(*txs: EthTx, msg_version="1.4", evm_denom=DEFAULT_DENOM) -> str: """ return base64 encoded cosmos tx, support batch """ - msgs = [ - cosmostx.build_any( - "/ethermint.evm.v1.MsgEthereumTx", - cosmostx.MsgEthereumTx( - from_=tx.sender, - raw=tx.raw, - ), - ) - for tx in txs - ] + build_msg = MSG_VERSIONS[msg_version] + msgs = [build_msg(tx) for tx in txs] fee = sum(tx.tx["gas"] * tx.tx["gasPrice"] for tx in txs) gas = sum(tx.tx["gas"] for tx in txs) body = cosmostx.TxBody( @@ -143,7 +218,7 @@ def build_cosmos_tx(*txs: EthTx) -> str: ) auth_info = cosmostx.AuthInfo( fee=cosmostx.Fee( - amount=[cosmostx.Coin(denom=DEFAULT_DENOM, amount=str(fee))], + amount=[cosmostx.Coin(denom=evm_denom, amount=str(fee))], gas_limit=gas, ) ) @@ -154,31 +229,36 @@ def build_cosmos_tx(*txs: EthTx) -> str: ).decode() +def json_rpc_send_body(raw, method="broadcast_tx_async"): + return { + "jsonrpc": "2.0", + "method": method, + "params": {"tx": raw}, + "id": 1, + } + + @backoff.on_predicate(backoff.expo, max_time=60, max_value=5) @backoff.on_exception(backoff.expo, aiohttp.ClientError, max_time=60, max_value=5) -async def async_sendtx(session, raw): - async with session.post( - LOCAL_RPC, - json={ - "jsonrpc": "2.0", - "method": "broadcast_tx_async", - "params": { - "tx": raw, - }, - "id": 1, - }, - ) as rsp: +async def async_sendtx(session, raw, rpc, sync=False): + method = "broadcast_tx_sync" if sync else "broadcast_tx_async" + async with session.post(rpc, json=json_rpc_send_body(raw, method)) as rsp: data = await rsp.json() if "error" in data: print("send tx error, will retry,", data["error"]) return False + result = data["result"] + if result["code"] != 0: + print("tx is invalid, won't retry,", result["log"]) return True -async def send(txs): +async def send(txs, rpc=LOCAL_RPC, sync=False): connector = aiohttp.TCPConnector(limit=CONNECTION_POOL_SIZE) async with aiohttp.ClientSession( connector=connector, json_serialize=ujson.dumps ) as session: - tasks = [asyncio.ensure_future(async_sendtx(session, raw)) for raw in txs] + tasks = [ + asyncio.ensure_future(async_sendtx(session, raw, rpc, sync)) for raw in txs + ] await asyncio.gather(*tasks) diff --git a/testground/benchmark/benchmark/utils.py b/testground/benchmark/benchmark/utils.py index 5d71f74122..786b7f17fa 100644 --- a/testground/benchmark/benchmark/utils.py +++ b/testground/benchmark/benchmark/utils.py @@ -162,18 +162,18 @@ def gen_account(global_seq: int, index: int) -> Account: return Account.from_key(((global_seq + 1) << 32 | index).to_bytes(32)) -def block_height(): - rsp = requests.get(f"{LOCAL_RPC}/status").json() +def block_height(rpc=LOCAL_RPC): + rsp = requests.get(f"{rpc}/status").json() return int(rsp["result"]["sync_info"]["latest_block_height"]) -def block(height): - return requests.get(f"{LOCAL_RPC}/block?height={height}").json() +def block(height, rpc=LOCAL_RPC): + return requests.get(f"{rpc}/block?height={height}").json() -def block_eth(height: int): +def block_eth(height: int, json_rpc=LOCAL_JSON_RPC): return requests.post( - f"{LOCAL_JSON_RPC}", + json_rpc, json={ "jsonrpc": "2.0", "method": "eth_getBlockByNumber", @@ -183,8 +183,8 @@ def block_eth(height: int): ).json()["result"] -def block_txs(height): - return block(height)["result"]["block"]["data"]["txs"] +def block_txs(height, rpc=LOCAL_RPC): + return block(height, rpc=rpc)["result"]["block"]["data"]["txs"] def split(a: int, n: int): diff --git a/testground/benchmark/flake.nix b/testground/benchmark/flake.nix index f29572553a..234cf58638 100644 --- a/testground/benchmark/flake.nix +++ b/testground/benchmark/flake.nix @@ -33,6 +33,10 @@ type = "app"; program = "${pkgs.benchmark-testcase}/bin/stateless-testcase"; }; + testnet = { + type = "app"; + program = "${pkgs.benchmark-testcase}/bin/testnet"; + }; }; devShells.default = pkgs.mkShell { buildInputs = [ pkgs.benchmark-testcase-env ]; diff --git a/testground/benchmark/pyproject.toml b/testground/benchmark/pyproject.toml index ed4953d96a..8ff96e6920 100644 --- a/testground/benchmark/pyproject.toml +++ b/testground/benchmark/pyproject.toml @@ -35,6 +35,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry.scripts] stateless-testcase = "benchmark.stateless:cli" +testnet = "benchmark.testnet:cli" [tool.black] line-length = 88