diff --git a/CHANGELOG.md b/CHANGELOG.md index 8734ce4c5e..a57197e143 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## UNRELEASED + +### Improvements + +* [#]() Add testnet benchmark command. + *Oct 24, 2024* ## v1.4.0-rc2 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/testnet.py b/testground/benchmark/benchmark/testnet.py new file mode 100644 index 0000000000..b44560d552 --- /dev/null +++ b/testground/benchmark/benchmark/testnet.py @@ -0,0 +1,121 @@ +import asyncio +import json +import sys +import time +from pathlib import Path + +import click +import requests +import web3 +from hexbytes import HexBytes + +from .transaction import EthTx, build_cosmos_tx, gen, json_rpc_send_body, send +from .utils import 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("--msg-version", default="1.3") +def gen_txs(start, end, num_txs, nonce, msg_version): + num_accounts = end - start + 1 + txs = gen( + GLOBAL_SEQ, + num_accounts, + num_txs, + "simple-transfer", + 1, + start_account=start, + nonce=nonce, + msg_version=msg_version, + ) + json.dump(txs, sys.stdout) + + +@cli.command() +@click.argument("path", type=str) +@click.option("--rpc", default=TESTNET_RPC) +def send_txs(path, rpc): + txs = json.loads(Path(path).read_text()) + asyncio.run(send(txs, rpc)) + + +if __name__ == "__main__": + cli() diff --git a/testground/benchmark/benchmark/transaction.py b/testground/benchmark/benchmark/transaction.py index 5ef86bedfc..311985d354 100644 --- a/testground/benchmark/benchmark/transaction.py +++ b/testground/benchmark/benchmark/transaction.py @@ -10,6 +10,7 @@ import backoff import eth_abi import ujson +from eth_account._utils.legacy_transactions import Transaction from hexbytes import HexBytes from . import cosmostx @@ -21,6 +22,21 @@ CONNECTION_POOL_SIZE = 1024 TXS_DIR = "txs" +Job = namedtuple( + "Job", + [ + "chunk", + "global_seq", + "num_txs", + "tx_type", + "create_tx", + "batch", + "nonce", + "msg_version", + ], +) +EthTx = namedtuple("EthTx", ["tx", "raw", "sender"]) + def simple_transfer_tx(sender: str, nonce: int): return { @@ -53,11 +69,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,7 +120,7 @@ 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) raw = acct.sign_transaction(tx).rawTransaction txs.append(EthTx(tx, raw, HexBytes(acct.address))) total += 1 @@ -76,19 +129,37 @@ def _do_job(job: Job): # 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) 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", +) -> [str]: 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, + ) + for start, end in chunks ] with multiprocessing.Pool() as pool: @@ -119,20 +190,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 +206,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,20 +217,19 @@ 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): + async with session.post(rpc, json=json_rpc_send_body(raw)) as rsp: data = await rsp.json() if "error" in data: print("send tx error, will retry,", data["error"]) @@ -175,10 +237,10 @@ async def async_sendtx(session, raw): return True -async def send(txs): +async def send(txs, rpc=LOCAL_RPC): 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)) for raw in txs] await asyncio.gather(*tasks) 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