diff --git a/.gitignore b/.gitignore index e783d49e5..2ca567ee5 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,6 @@ etherscan_requests/ # todos TODO.md + +# Agent0 fuzzing output files +.crash_report \ No newline at end of file diff --git a/contracts/src/interfaces/IHyperdriveEvents.sol b/contracts/src/interfaces/IHyperdriveEvents.sol index cb7c60cc2..2770e4d18 100644 --- a/contracts/src/interfaces/IHyperdriveEvents.sol +++ b/contracts/src/interfaces/IHyperdriveEvents.sol @@ -103,7 +103,7 @@ interface IHyperdriveEvents is IMultiTokenEvents { ); /// @notice Emitted when a pair of long and short positions are minted. - event Mint( + event MintBonds( address indexed longTrader, address indexed shortTrader, uint256 indexed maturityTime, @@ -117,7 +117,7 @@ interface IHyperdriveEvents is IMultiTokenEvents { ); /// @notice Emitted when a pair of long and short positions are burned. - event Burn( + event BurnBonds( address indexed trader, address indexed destination, uint256 indexed maturityTime, diff --git a/contracts/src/internal/HyperdrivePair.sol b/contracts/src/internal/HyperdrivePair.sol index 9a1d0dcff..63203be0b 100644 --- a/contracts/src/internal/HyperdrivePair.sol +++ b/contracts/src/internal/HyperdrivePair.sol @@ -124,7 +124,7 @@ abstract contract HyperdrivePair is IHyperdriveEvents, HyperdriveLP { uint256 bondAmount_ = bondAmount; // avoid stack-too-deep uint256 amount = _amount; // avoid stack-too-deep IHyperdrive.PairOptions calldata options = _options; // avoid stack-too-deep - emit Mint( + emit MintBonds( options.longDestination, options.shortDestination, maturityTime, @@ -252,7 +252,7 @@ abstract contract HyperdrivePair is IHyperdriveEvents, HyperdriveLP { // Emit a Burn event. uint256 bondAmount = _bondAmount; // avoid stack-too-deep IHyperdrive.Options calldata options = _options; // avoid stack-too-deep - emit Burn( + emit BurnBonds( msg.sender, options.destination, _maturityTime, diff --git a/python-fuzz/README.md b/python-fuzz/README.md new file mode 100644 index 000000000..ec6c5ad3c --- /dev/null +++ b/python-fuzz/README.md @@ -0,0 +1,32 @@ +# Python fuzzing for mint/burn + +This directory details how to install and run fuzzing on hyperdrive with mint/burn. + +## Installation + +First, compile the solidity contracts and make python types locally via `make`. + +Next, follow the prerequisites installation instructions of [agent0](https://github.com/delvtech/agent0/blob/main/INSTALL.md). +Then install [uv](https://github.com/astral-sh/uv) for package management. No need to clone the repo locally +(unless developing on agent0). + +From the base directory of the `hyperdrive` repo, set up a python virtual environment: + +``` +uv venv --python 3.10 .venv +source .venv/bin/activate +``` + +From here, you can install the generated python types and agent0 via: + +``` +uv pip install -r python-fuzz/requirements.txt +``` + +## Running fuzzing + +To run fuzzing, simply run the `fuzz_mint_burn.py` script: + +``` +python fuzz_mint_burn.py +``` diff --git a/python-fuzz/fuzz_mint_burn.py b/python-fuzz/fuzz_mint_burn.py new file mode 100644 index 000000000..a48599938 --- /dev/null +++ b/python-fuzz/fuzz_mint_burn.py @@ -0,0 +1,335 @@ +"""Bots for fuzzing hyperdrive, along with mint/burn. +""" + +from __future__ import annotations + +import argparse +import logging +import os +import random +import sys +from typing import NamedTuple, Sequence + +import numpy as np +from agent0 import LocalChain, LocalHyperdrive +from agent0.hyperfuzz.system_fuzz import generate_fuzz_hyperdrive_config, run_fuzz_bots +from agent0.hyperlogs.rollbar_utilities import initialize_rollbar, log_rollbar_exception +from fixedpointmath import FixedPoint +from hyperdrivetypes.types.IHyperdrive import Options, PairOptions +from pypechain.core import PypechainCallException +from web3.exceptions import ContractCustomError + + +def _fuzz_ignore_logging_to_rollbar(exc: Exception) -> bool: + """Function defining errors to not log to rollbar during fuzzing. + + These are the two most common errors we see in local fuzz testing. These are + known issues due to random bots not accounting for these cases, so we don't log them to + rollbar. + """ + if isinstance(exc, PypechainCallException): + orig_exception = exc.orig_exception + if orig_exception is None: + return False + + # Insufficient liquidity error + if isinstance(orig_exception, ContractCustomError) and exc.decoded_error == "InsufficientLiquidity()": + return True + + # Circuit breaker triggered error + if isinstance(orig_exception, ContractCustomError) and exc.decoded_error == "CircuitBreakerTriggered()": + return True + + return False + + +def _fuzz_ignore_errors(exc: Exception) -> bool: + """Function defining errors to ignore during fuzzing of hyperdrive pools.""" + # pylint: disable=too-many-return-statements + # pylint: disable=too-many-branches + # Ignored fuzz exceptions + + # Contract call exceptions + if isinstance(exc, PypechainCallException): + orig_exception = exc.orig_exception + if orig_exception is None: + return False + + # Insufficient liquidity error + if isinstance(orig_exception, ContractCustomError) and exc.decoded_error == "InsufficientLiquidity()": + return True + + # Circuit breaker triggered error + if isinstance(orig_exception, ContractCustomError) and exc.decoded_error == "CircuitBreakerTriggered()": + return True + + # DistributeExcessIdle error + if isinstance(orig_exception, ContractCustomError) and exc.decoded_error == "DistributeExcessIdleFailed()": + return True + + # MinimumTransactionAmount error + if isinstance(orig_exception, ContractCustomError) and exc.decoded_error == "MinimumTransactionAmount()": + return True + + # DecreasedPresentValueWhenAddingLiquidity error + if ( + isinstance(orig_exception, ContractCustomError) + and exc.decoded_error == "DecreasedPresentValueWhenAddingLiquidity()" + ): + return True + + # Closing long results in fees exceeding long proceeds + if len(exc.args) > 1 and "Closing the long results in fees exceeding long proceeds" in exc.args[0]: + return True + + # # Status == 0 + # if ( + # isinstance(orig_exception, FailedTransaction) + # and len(orig_exception.args) > 0 + # and "Receipt has status of 0" in orig_exception.args[0] + # ): + # return True + + return False + + +def main(argv: Sequence[str] | None = None) -> None: + """Runs the mint/burn fuzzing. + + Arguments + --------- + argv: Sequence[str] + The argv values returned from argparser. + """ + # pylint: disable=too-many-branches + + parsed_args = parse_arguments(argv) + + # Negative rng_seed means default + if parsed_args.rng_seed < 0: + rng_seed = random.randint(0, 10000000) + else: + rng_seed = parsed_args.rng_seed + rng = np.random.default_rng(rng_seed) + + # Set up rollbar + # TODO log additional crashes + rollbar_environment_name = "fuzz_mint_burn" + log_to_rollbar = initialize_rollbar(rollbar_environment_name) + + # Set up chain config + local_chain_config = LocalChain.Config( + block_timestamp_interval=12, + log_level_threshold=logging.WARNING, + preview_before_trade=True, + log_to_rollbar=log_to_rollbar, + rollbar_log_prefix="localfuzzbots", + rollbar_log_filter_func=_fuzz_ignore_logging_to_rollbar, + rng=rng, + crash_log_level=logging.ERROR, + rollbar_log_level_threshold=logging.ERROR, # Only log errors and above to rollbar + crash_report_additional_info={"rng_seed": rng_seed}, + gas_limit=int(1e6), # Plenty of gas limit for transactions + ) + + while True: + # Build interactive local hyperdrive + # TODO can likely reuse some of these resources + # instead, we start from scratch every time. + chain = LocalChain(local_chain_config) + + try: + # Fuzz over config values + hyperdrive_config = generate_fuzz_hyperdrive_config(rng, lp_share_price_test=False, steth=False) + + try: + hyperdrive_pool = LocalHyperdrive(chain, hyperdrive_config) + except Exception as e: # pylint: disable=broad-except + logging.error( + "Error deploying hyperdrive: %s", + repr(e), + ) + log_rollbar_exception( + e, + log_level=logging.ERROR, + rollbar_log_prefix="Error deploying hyperdrive poolError deploying hyperdrive pool", + ) + chain.cleanup() + continue + + agents = None + + # Run the fuzzing bot for an episode + for _ in range(parsed_args.num_iterations_per_episode): + # Run fuzzing via agent0 function on underlying hyperdrive pool. + # By default, this sets up 4 agents. + # `check_invariance` also runs the pool's invariance checks after trades. + # We only run for 1 iteration here, as we want to make additional random trades + # wrt mint/burn. + agents = run_fuzz_bots( + chain, + hyperdrive_pools=[hyperdrive_pool], + # We pass in the same agents when running fuzzing + agents=agents, + check_invariance=True, + raise_error_on_failed_invariance_checks=True, + raise_error_on_crash=True, + log_to_rollbar=log_to_rollbar, + ignore_raise_error_func=_fuzz_ignore_errors, + random_advance_time=True, + random_variable_rate=True, # Variable rate can change between 0% and 100% + lp_share_price_test=False, + base_budget_per_bot=FixedPoint(1_000_000), + num_iterations=1, + minimum_avg_agent_base=FixedPoint(100_000), + ) + + # Get access to the underlying hyperdrive contract for pypechain calls + hyperdrive_contract = hyperdrive_pool.interface.hyperdrive_contract + + # Run random vault mint/burn + for agent in agents: + # Pick mint or burn at random + trade = chain.config.rng.choice(["mint", "burn"]) # type: ignore + match trade: + case "mint": + balance = agent.get_wallet(hyperdrive_pool).balance.amount + if balance > hyperdrive_config.minimum_transaction_amount: + # TODO can't use numpy rng since it doesn't support uint256. + # Need to use the state from the chain config to use the same rng object. + amount = random.randint(hyperdrive_config.minimum_transaction_amount.scaled_value, balance.scaled_value) + pair_options = PairOptions( + longDestination=agent.address, + shortDestination=agent.address, + asBase=True, + extraData=bytes(0), + ) + hyperdrive_contract.functions.mint( + _amount=amount, _minOutput=0, _minVaultSharePrice=0, _options=pair_options + ).sign_transact_and_wait(account=agent.account, validate_transaction=True) + + case "burn": + wallet = agent.get_wallet(hyperdrive_pool) + + # Find maturity times that have both long and short positions + matching_maturities = set(wallet.longs.keys()) & set(wallet.shorts.keys()) + + if matching_maturities: + selected_maturity = random.choice(list(matching_maturities)) + + # Get positions for selected maturity + long_balance = wallet.longs[selected_maturity].balance + short_balance = wallet.shorts[selected_maturity].balance + max_burnable = min(long_balance, short_balance) + + if max_burnable > hyperdrive_config.minimum_transaction_amount: + burn_amount = random.randint( + hyperdrive_config.minimum_transaction_amount.scaled_value, + max_burnable.scaled_value + ) + logging.info( + f"Agent {agent.address} is burning {burn_amount} of positions " + f"with maturity time {selected_maturity}" + ) + options = Options( + destination=agent.address, + asBase=True, + extraData=bytes(0) + ) + hyperdrive_contract.functions.burn( + _maturityTime=selected_maturity, + _bondAmount=burn_amount, + _minOutput=0, + _options=options + ).sign_transact_and_wait( + account=agent.account, + validate_transaction=True + ) + + # Catch any exceptions and pause until user input is provided. + except Exception as e: + logging.error("Error during fuzzing: %s", repr(e)) + log_rollbar_exception(e, log_level=logging.ERROR, rollbar_log_prefix="Fuzzing error") + + # Keep anvil running and wait for debug connection + input("Press Enter to continue after debugging...") + + # Cleanup and start fresh iteration + chain.cleanup() + continue + + +class Args(NamedTuple): + """Command line arguments for fuzzing mint/burn.""" + + rng_seed: int + num_iterations_per_episode: int + + +def namespace_to_args(namespace: argparse.Namespace) -> Args: + """Converts argprase.Namespace to Args. + + Arguments + --------- + namespace: argparse.Namespace + Object for storing arg attributes. + + Returns + ------- + Args + Formatted arguments + """ + return Args( + rng_seed=namespace.rng_seed, + num_iterations_per_episode=namespace.num_iterations_per_episode, + ) + + +def parse_arguments(argv: Sequence[str] | None = None) -> Args: + """Parses input arguments. + + Arguments + --------- + argv: Sequence[str] + The argv values returned from argparser. + + Returns + ------- + Args + Formatted arguments + """ + parser = argparse.ArgumentParser(description="Runs fuzzing mint/burn") + + parser.add_argument( + "--rng-seed", + type=int, + default=-1, + help="The random seed to use for the fuzz run.", + ) + parser.add_argument( + "--num-iterations-per-episode", + default=3000, + help="The number of iterations to run for each random pool config.", + ) + + # Use system arguments if none were passed + if argv is None: + argv = sys.argv + return namespace_to_args(parser.parse_args()) + + +# Run fuzing +if __name__ == "__main__": + # Wrap everything in a try catch to log any non-caught critical errors and log to rollbar + try: + main() + except BaseException as exc: # pylint: disable=broad-except + # pylint: disable=invalid-name + _rpc_uri = os.getenv("RPC_URI", None) + if _rpc_uri is None: + _log_prefix = "Uncaught Critical Error in Fuzzing mint/burn:" + else: + _chain_name = _rpc_uri.split("//")[-1].split("/")[0] + _log_prefix = f"Uncaught Critical Error for {_chain_name} in Fuzz mint/burn:" + log_rollbar_exception(exception=exc, log_level=logging.CRITICAL, rollbar_log_prefix=_log_prefix) + raise exc diff --git a/python-fuzz/requirements.txt b/python-fuzz/requirements.txt new file mode 100644 index 000000000..bd23c89f2 --- /dev/null +++ b/python-fuzz/requirements.txt @@ -0,0 +1,2 @@ +-e python/hyperdrivetypes +-e ../agent0 diff --git a/test/units/hyperdrive/BurnTest.t.sol b/test/units/hyperdrive/BurnTest.t.sol index 14a0e531a..688e80576 100644 --- a/test/units/hyperdrive/BurnTest.t.sol +++ b/test/units/hyperdrive/BurnTest.t.sol @@ -631,7 +631,7 @@ contract BurnTest is HyperdriveTest { uint256 _proceeds ) internal { VmSafe.Log[] memory logs = vm.getRecordedLogs().filterLogs( - Burn.selector + BurnBonds.selector ); assertEq(logs.length, 1); VmSafe.Log memory log = logs[0]; diff --git a/test/units/hyperdrive/MintTest.t.sol b/test/units/hyperdrive/MintTest.t.sol index 0835f157b..b557d0eb5 100644 --- a/test/units/hyperdrive/MintTest.t.sol +++ b/test/units/hyperdrive/MintTest.t.sol @@ -473,7 +473,7 @@ contract MintTest is HyperdriveTest { uint256 _bondAmount ) internal { VmSafe.Log[] memory logs = vm.getRecordedLogs().filterLogs( - Mint.selector + MintBonds.selector ); assertEq(logs.length, 1); VmSafe.Log memory log = logs[0];