diff --git a/CHANGELOG.md b/CHANGELOG.md index bc277c1da..11cbd3b75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ - [\#398](https://github.com/cosmos/evm/pull/398) Post-audit security fixes (batch 4) - [\#442](https://github.com/cosmos/evm/pull/442) Prevent nil pointer by checking error in gov precompile FromResponse. - [\#387](https://github.com/cosmos/evm/pull/387) (Experimental) EVM-compatible appside mempool +- [\#476](https://github.com/cosmos/evm/pull/476) Add revert error e2e tests for contract and precompile calls ### FEATURES diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..819063d72 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "evm", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/tests/solidity/init-node.sh b/tests/solidity/init-node.sh deleted file mode 100755 index 249b6a532..000000000 --- a/tests/solidity/init-node.sh +++ /dev/null @@ -1,145 +0,0 @@ -#!/bin/bash - -set -ex - -# TODO: remove this script and just use the local node script for it, add flag to start node in given directory - -CHAINID="${CHAIN_ID:-cosmos_262144-1}" -MONIKER="localtestnet" -KEYRING="test" # remember to change to other types of keyring like 'file' in-case exposing to outside world, otherwise your balance will be wiped quickly. The keyring test does not require private key to steal tokens from you -KEYALGO="eth_secp256k1" #gitleaks:allow -LOGLEVEL="info" -# to trace evm -#TRACE="--trace" -TRACE="" -PRUNING="default" -#PRUNING="custom" - -CHAINDIR="$HOME/.tmp-evmd-solidity-tests" # TODO: make configurable like chain id -GENESIS="$CHAINDIR/config/genesis.json" -TMP_GENESIS="$CHAINDIR/config/tmp_genesis.json" -APP_TOML="$CHAINDIR/config/app.toml" -CONFIG_TOML="$CHAINDIR/config/config.toml" - -# make sure to reset chain directory before test -rm -rf "$CHAINDIR" - -# validate dependencies are installed -command -v jq >/dev/null 2>&1 || { - echo >&2 "jq not installed. More info: https://stedolan.github.io/jq/download/" - exit 1 -} - -# used to exit on first error (any non-zero exit code) -set -e - -# feemarket params basefee -BASEFEE=1000000000 - -# Set client config -evmd config set client chain-id "$CHAINID" --home "$CHAINDIR" -evmd config set client keyring-backend "$KEYRING" --home "$CHAINDIR" - -# myKey address 0x7cb61d4117ae31a12e393a1cfa3bac666481d02e -VAL_KEY="mykey" -VAL_MNEMONIC="gesture inject test cycle original hollow east ridge hen combine junk child bacon zero hope comfort vacuum milk pitch cage oppose unhappy lunar seat" - -# user1 address 0xc6fe5d33615a1c52c08018c47e8bc53646a0e101 -USER1_KEY="user1" -USER1_MNEMONIC="copper push brief egg scan entry inform record adjust fossil boss egg comic alien upon aspect dry avoid interest fury window hint race symptom" - -# user2 address 0x963ebdf2e1f8db8707d05fc75bfeffba1b5bac17 -USER2_KEY="user2" -USER2_MNEMONIC="maximum display century economy unlock van census kite error heart snow filter midnight usage egg venture cash kick motor survey drastic edge muffin visual" - -# user3 address 0x40a0cb1C63e026A81B55EE1308586E21eec1eFa9 -USER3_KEY="user3" -USER3_MNEMONIC="will wear settle write dance topic tape sea glory hotel oppose rebel client problem era video gossip glide during yard balance cancel file rose" - -# user4 address 0x498B5AeC5D439b733dC2F58AB489783A23FB26dA -USER4_KEY="user4" -USER4_MNEMONIC="doll midnight silk carpet brush boring pluck office gown inquiry duck chief aim exit gain never tennis crime fragile ship cloud surface exotic patch" - -# Import keys from mnemonics -echo "$VAL_MNEMONIC" | evmd keys add "$VAL_KEY" --recover --keyring-backend "$KEYRING" --algo "$KEYALGO" --home "$CHAINDIR" -echo "$USER1_MNEMONIC" | evmd keys add "$USER1_KEY" --recover --keyring-backend "$KEYRING" --algo "$KEYALGO" --home "$CHAINDIR" -echo "$USER2_MNEMONIC" | evmd keys add "$USER2_KEY" --recover --keyring-backend "$KEYRING" --algo "$KEYALGO" --home "$CHAINDIR" -echo "$USER3_MNEMONIC" | evmd keys add "$USER3_KEY" --recover --keyring-backend "$KEYRING" --algo "$KEYALGO" --home "$CHAINDIR" -echo "$USER4_MNEMONIC" | evmd keys add "$USER4_KEY" --recover --keyring-backend "$KEYRING" --algo "$KEYALGO" --home "$CHAINDIR" - -# Set moniker and chain-id for Cosmos EVM (Moniker can be anything, chain-id must be an integer) -evmd init "$MONIKER" --chain-id "$CHAINID" --home "$CHAINDIR" - -# Change parameter token denominations to atest -jq '.app_state["staking"]["params"]["bond_denom"]="atest"' "$GENESIS" >"$TMP_GENESIS" && mv "$TMP_GENESIS" "$GENESIS" -jq '.app_state["gov"]["deposit_params"]["min_deposit"][0]["denom"]="atest"' "$GENESIS" >"$TMP_GENESIS" && mv "$TMP_GENESIS" "$GENESIS" -jq '.app_state["gov"]["params"]["min_deposit"][0]["denom"]="atest"' "$GENESIS" >"$TMP_GENESIS" && mv "$TMP_GENESIS" "$GENESIS" -jq '.app_state["evm"]["params"]["evm_denom"]="atest"' "$GENESIS" >"$TMP_GENESIS" && mv "$TMP_GENESIS" "$GENESIS" -jq '.app_state["mint"]["params"]["mint_denom"]="atest"' "$GENESIS" >"$TMP_GENESIS" && mv "$TMP_GENESIS" "$GENESIS" - -# Enable precompiles in EVM params -jq '.app_state["evm"]["params"]["active_static_precompiles"]=["0x0000000000000000000000000000000000000100","0x0000000000000000000000000000000000000400","0x0000000000000000000000000000000000000800","0x0000000000000000000000000000000000000801","0x0000000000000000000000000000000000000802","0x0000000000000000000000000000000000000803","0x0000000000000000000000000000000000000804"]' "$GENESIS" >"$TMP_GENESIS" && mv "$TMP_GENESIS" "$GENESIS" - -# Change proposal periods to pass within a reasonable time for local testing -sed -i.bak 's/"max_deposit_period": "172800s"/"max_deposit_period": "30s"/g' "$GENESIS" -sed -i.bak 's/"voting_period": "172800s"/"voting_period": "30s"/g' "$GENESIS" -sed -i.bak 's/"expedited_voting_period": "86400s"/"expedited_voting_period": "15s"/g' "$GENESIS" - -# Set gas limit in genesis -jq '.consensus_params.block.max_gas="10000000"' "$GENESIS" >"$TMP_GENESIS" && mv "$TMP_GENESIS" "$GENESIS" - -# Set base fee in genesis -jq '.app_state["feemarket"]["params"]["base_fee"]="'${BASEFEE}'"' "$GENESIS" >"$TMP_GENESIS" && mv "$TMP_GENESIS" "$GENESIS" - -# disable produce empty block -sed -i.bak 's/create_empty_blocks = true/create_empty_blocks = false/g' "$CONFIG_TOML" - -# Allocate genesis accounts (cosmos formatted addresses) -evmd genesis add-genesis-account "$(evmd keys show "$VAL_KEY" -a --keyring-backend "$KEYRING" --home "$CHAINDIR")" 100000000000000000000000000atest --keyring-backend "$KEYRING" --home "$CHAINDIR" -evmd genesis add-genesis-account "$(evmd keys show "$USER1_KEY" -a --keyring-backend "$KEYRING" --home "$CHAINDIR")" 1000000000000000000000atest --keyring-backend "$KEYRING" --home "$CHAINDIR" -evmd genesis add-genesis-account "$(evmd keys show "$USER2_KEY" -a --keyring-backend "$KEYRING" --home "$CHAINDIR")" 1000000000000000000000atest --keyring-backend "$KEYRING" --home "$CHAINDIR" -evmd genesis add-genesis-account "$(evmd keys show "$USER3_KEY" -a --keyring-backend "$KEYRING" --home "$CHAINDIR")" 1000000000000000000000atest --keyring-backend "$KEYRING" --home "$CHAINDIR" -evmd genesis add-genesis-account "$(evmd keys show "$USER4_KEY" -a --keyring-backend "$KEYRING" --home "$CHAINDIR")" 1000000000000000000000atest --keyring-backend "$KEYRING" --home "$CHAINDIR" - -# set custom pruning settings -if [ "$PRUNING" = "custom" ]; then - sed -i.bak 's/pruning = "default"/pruning = "custom"/g' "$APP_TOML" - sed -i.bak 's/pruning-keep-recent = "0"/pruning-keep-recent = "2"/g' "$APP_TOML" - sed -i.bak 's/pruning-interval = "0"/pruning-interval = "10"/g' "$APP_TOML" -fi - -# make sure the localhost IP is 0.0.0.0 -sed -i.bak 's/localhost/0.0.0.0/g' "$CONFIG_TOML" -sed -i.bak 's/127.0.0.1/0.0.0.0/g' "$APP_TOML" - -# use timeout_commit 1s to make test faster -sed -i.bak 's/timeout_commit = "5s"/timeout_commit = "100ms"/g' "$CONFIG_TOML" - -# Sign genesis transaction -evmd genesis gentx "$VAL_KEY" 1000000000000000000000atest --gas-prices ${BASEFEE}atest --keyring-backend "$KEYRING" --chain-id "$CHAINID" --home "$CHAINDIR" -## In case you want to create multiple validators at genesis -## 1. Back to `evmd keys add` step, init more keys -## 2. Back to `evmd add-genesis-account` step, add balance for those -## 3. Clone this ~/.evmd home directory into some others, let's say `~/.clonedosd` -## 4. Run `gentx` in each of those folders -## 5. Copy the `gentx-*` folders under `~/.clonedosd/config/gentx/` folders into the original `~/.evmd/config/gentx` - -# Enable the APIs for the tests to be successful -sed -i.bak 's/enable = false/enable = true/g' "$APP_TOML" - -# Don't enable memiavl by default -grep -q -F '[memiavl]' "$APP_TOML" && sed -i.bak '/\[memiavl\]/,/^\[/ s/enable = true/enable = false/' "$APP_TOML" - -# Collect genesis tx -evmd genesis collect-gentxs --home "$CHAINDIR" - -# Run this to ensure everything worked and that the genesis file is setup correctly -evmd genesis validate-genesis --home "$CHAINDIR" - -# Start the node -evmd start "$TRACE" \ - --log_level $LOGLEVEL \ - --minimum-gas-prices=0.0001utest \ - --json-rpc.api eth,txpool,personal,net,debug,web3 \ - --chain-id "$CHAINID" \ - --home "$CHAINDIR" diff --git a/tests/solidity/suites/revert_cases/contracts/RevertTestContract.sol b/tests/solidity/suites/revert_cases/contracts/RevertTestContract.sol new file mode 100644 index 000000000..d20f80c3e --- /dev/null +++ b/tests/solidity/suites/revert_cases/contracts/RevertTestContract.sol @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "./cosmos/staking/StakingI.sol"; +import "./cosmos/distribution/DistributionI.sol"; +import "./cosmos/bank/IBank.sol"; +import "./cosmos/common/Types.sol"; + +/** + * @title RevertTestContract + * @dev Contract for testing Cosmos precompile revert scenarios and error message handling + * Focuses specifically on precompile calls and interactions with Cosmos SDK modules + */ +contract RevertTestContract { + uint256 public counter = 0; + + // Events to track what operations are performed + event PrecompileCallMade(string precompileName, bool success); + event OutOfGasSimulated(uint256 gasLeft); + + constructor() payable {} + + // ============ DIRECT PRECOMPILE CALL REVERTS ============ + + /** + * @dev Direct staking precompile call that will revert + */ + function directStakingRevert(string calldata invalidValidator) external { + counter++; + emit PrecompileCallMade("staking", false); + // This should revert with invalid validator address + STAKING_CONTRACT.delegate(address(this), invalidValidator, 1); + } + + /** + * @dev Direct distribution precompile call that will revert + */ + function directDistributionRevert(string calldata invalidValidator) external { + counter++; + emit PrecompileCallMade("distribution", false); + // This should revert with invalid validator address + DISTRIBUTION_CONTRACT.withdrawDelegatorRewards(address(this), invalidValidator); + } + + /** + * @dev Direct bank precompile call that will revert + */ + function directBankRevert() external pure { + revert("intended revert"); + } + + // ============ PRECOMPILE CALL VIA CONTRACT REVERTS ============ + + /** + * @dev Precompile call via contract that reverts + */ + function precompileViaContractRevert(string calldata invalidValidator) external { + counter++; + try this.internalStakingCall(invalidValidator) { + // Should not reach here + } catch (bytes memory reason) { + // Re-throw the error to maintain the revert + assembly { + revert(add(reason, 0x20), mload(reason)) + } + } + } + + /** + * @dev Internal function for precompile call via contract + */ + function internalStakingCall(string calldata validatorAddress) external { + require(msg.sender == address(this), "Only self can call"); + emit PrecompileCallMade("staking_internal", false); + STAKING_CONTRACT.delegate(address(this), validatorAddress, 1); + } + + /** + * @dev Complex scenario: multiple precompile calls with revert + */ + function multiplePrecompileCallsWithRevert(string calldata validatorAddress) external { + counter++; + + // First, make a successful call + try IBANK_CONTRACT.balances(address(this)) returns (Balance[] memory) { + emit PrecompileCallMade("bank", true); + } catch { + emit PrecompileCallMade("bank", false); + } + + // Then make a call that will revert + emit PrecompileCallMade("staking_multi", false); + STAKING_CONTRACT.delegate(address(this), validatorAddress, 1); + } + + // ============ PRECOMPILE OUT OF GAS ERROR CASES ============ + + /** + * @dev Direct precompile call that runs out of gas + */ + function directStakingOutOfGas(string calldata validatorAddress) external { + counter++; + emit OutOfGasSimulated(gasleft()); + + // First consume most gas + for (uint256 i = 0; i < 1000000; i++) { + counter++; + } + + // Then try precompile call with remaining gas + STAKING_CONTRACT.delegate(address(this), validatorAddress, 1); + } + + /** + * @dev Precompile call via contract that runs out of gas + */ + function precompileViaContractOutOfGas(string calldata validatorAddress) external { + counter++; + emit OutOfGasSimulated(gasleft()); + + // Consume most gas first + for (uint256 i = 0; i < 1000000; i++) { + counter++; + } + + // Then try internal precompile call + this.internalStakingCall(validatorAddress); + } + + /** + * @dev Wrapper precompile call that runs out of gas + */ + function wrappedPrecompileOutOfGas(string calldata validatorAddress) external { + counter++; + emit OutOfGasSimulated(gasleft()); + + // Consume most gas in expensive operations + for (uint256 i = 0; i < 500000; i++) { + keccak256(abi.encode(i, block.timestamp, msg.sender)); + counter++; + } + + // Then try multiple precompile calls + STAKING_CONTRACT.delegate(address(this), validatorAddress, 1); + DISTRIBUTION_CONTRACT.withdrawDelegatorRewards(address(this), validatorAddress); + } + + // ============ UTILITY FUNCTIONS ============ + + /** + * @dev Get current counter value + */ + function getCounter() external view returns (uint256) { + return counter; + } + + /** + * @dev Reset counter (for testing) + */ + function resetCounter() external { + counter = 0; + } + + /** + * @dev Fund contract with native tokens + */ + receive() external payable {} + + /** + * @dev Withdraw funds (for testing) + */ + function withdraw() external { + payable(msg.sender).transfer(address(this).balance); + } +} + +/** + * @title PrecompileWrapper + * @dev Helper contract for testing precompile calls via external contracts + */ +contract PrecompileWrapper { + event WrapperCall(string operation, bool success); + + constructor() payable {} + + /** + * @dev Wrapper function that calls staking precompile and reverts + */ + function wrappedStakingCall(string calldata validatorAddress, uint256 amount) external { + emit WrapperCall("staking", false); + STAKING_CONTRACT.delegate(address(this), validatorAddress, amount); + revert("Wrapper intentional revert"); + } + + /** + * @dev Wrapper function that calls distribution precompile and reverts + */ + function wrappedDistributionCall(string calldata validatorAddress) external { + emit WrapperCall("distribution", false); + DISTRIBUTION_CONTRACT.withdrawDelegatorRewards(address(this), validatorAddress); + revert("Wrapper intentional revert"); + } + + /** + * @dev Wrapper function that runs out of gas + */ + function wrappedOutOfGasCall(string calldata validatorAddress) external { + // Consume all gas + for (uint256 i = 0; i < 1000000; i++) { + // Gas consuming operation + keccak256(abi.encode(i)); + } + + STAKING_CONTRACT.delegate(address(this), validatorAddress, 1); + } + + /** + * @dev Fund wrapper contract + */ + receive() external payable {} +} diff --git a/tests/solidity/suites/revert_cases/contracts/StandardRevertTestContract.sol b/tests/solidity/suites/revert_cases/contracts/StandardRevertTestContract.sol new file mode 100644 index 000000000..5ee137ecd --- /dev/null +++ b/tests/solidity/suites/revert_cases/contracts/StandardRevertTestContract.sol @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/** + * @title StandardRevertTestContract + * @dev Contract for testing standard Ethereum revert scenarios and error message handling + * Compatible with any Ethereum node (Geth, etc.) - no custom precompiles required + */ +contract StandardRevertTestContract { + uint256 public counter = 0; + + // Events to track what operations are performed + event StandardRevert(string reason); + event OutOfGasSimulated(uint256 gasLeft); + event CounterIncremented(uint256 newValue); + + constructor() payable {} + + // ============ STANDARD CONTRACT REVERT CASES ============ + + /** + * @dev Simple revert with custom message + */ + function standardRevert(string calldata reason) external { + counter++; + emit StandardRevert(reason); + revert(reason); + } + + /** + * @dev Revert with require statement + */ + function requireRevert(uint256 value, uint256 threshold) external { + counter++; + require(value < threshold, "Value exceeds threshold"); + emit CounterIncremented(counter); + } + + /** + * @dev Assert revert (should generate Panic error) + */ + function assertRevert() external { + counter++; + assert(false); + } + + /** + * @dev Division by zero (should generate Panic error) + */ + function divisionByZero() external pure returns (uint256) { + uint256 zero = 0; + return 1 / zero; + } + + /** + * @dev Array out of bounds (should generate Panic error) + */ + function arrayOutOfBounds() external pure returns (uint256) { + uint256[] memory arr = new uint256[](2); + return arr[5]; // This will cause an out of bounds error + } + + /** + * @dev Division by zero in transaction context (should generate Panic error) + */ + function divisionByZeroTx() external returns (uint256) { + counter++; // State change to make it a transaction + uint256 zero = 0; + return 1 / zero; + } + + /** + * @dev Array out of bounds in transaction context (should generate Panic error) + */ + function arrayOutOfBoundsTx() external returns (uint256) { + counter++; // State change to make it a transaction + uint256[] memory arr = new uint256[](2); + return arr[5]; // This will cause an out of bounds error + } + + /** + * @dev Overflow error (should generate Panic error in older Solidity) + */ + function overflowError() external pure returns (uint256) { + unchecked { + uint256 max = type(uint256).max; + return max + 1; // This might overflow depending on Solidity version + } + } + + // ============ OUT OF GAS ERROR CASES ============ + + /** + * @dev Standard contract call that runs out of gas + */ + function standardOutOfGas() external { + counter++; + emit OutOfGasSimulated(gasleft()); + + // Consume all remaining gas + while (gasleft() > 1000) { + // Consume gas in a loop + counter++; + } + } + + /** + * @dev Expensive computation that can run out of gas + */ + function expensiveComputation(uint256 iterations) external { + counter++; + emit OutOfGasSimulated(gasleft()); + + // Perform expensive operations + for (uint256 i = 0; i < iterations; i++) { + // Hash operations are gas-expensive + keccak256(abi.encode(i, block.timestamp, msg.sender)); + counter++; + } + } + + /** + * @dev Storage operations that can run out of gas + */ + function expensiveStorage(uint256 iterations) external { + counter++; + emit OutOfGasSimulated(gasleft()); + + // Storage operations are very gas-expensive + for (uint256 i = 0; i < iterations; i++) { + assembly { + sstore(add(counter.slot, i), i) + } + } + } + + // ============ COMPLEX REVERT SCENARIOS ============ + + /** + * @dev Multiple function calls with revert + */ + function multipleCallsWithRevert() external { + counter++; + + // First, do some successful operations + this.incrementCounter(); + + // Then revert + revert("Multiple calls revert"); + } + + /** + * @dev Try-catch with revert + */ + function tryCatchRevert(bool shouldRevert) external { + counter++; + + if (shouldRevert) { + try this.internalRevert() { + // Should not reach here + } catch (bytes memory reason) { + // Re-throw the error to maintain the revert + assembly { + revert(add(reason, 0x20), mload(reason)) + } + } + } else { + // Successful path + emit CounterIncremented(counter); + } + } + + /** + * @dev Internal function that always reverts + */ + function internalRevert() external pure { + revert("Internal function revert"); + } + + // ============ UTILITY FUNCTIONS ============ + + /** + * @dev Simple function that increments counter + */ + function incrementCounter() external { + counter++; + emit CounterIncremented(counter); + } + + /** + * @dev Get current counter value + */ + function getCounter() external view returns (uint256) { + return counter; + } + + /** + * @dev Reset counter (for testing) + */ + function resetCounter() external { + counter = 0; + } + + /** + * @dev Fund contract with native tokens + */ + receive() external payable {} + + /** + * @dev Withdraw funds (for testing) + */ + function withdraw() external { + payable(msg.sender).transfer(address(this).balance); + } + + /** + * @dev Get contract balance + */ + function getBalance() external view returns (uint256) { + return address(this).balance; + } +} + +/** + * @title SimpleWrapper + * @dev Helper contract for testing reverts via external contracts + */ +contract SimpleWrapper { + event WrapperCall(string operation, bool success); + + constructor() payable {} + + /** + * @dev Wrapper function that calls standard revert + */ + function wrappedStandardCall(StandardRevertTestContract target, string calldata reason) external { + emit WrapperCall("standard_revert", false); + target.standardRevert(reason); + } + + /** + * @dev Wrapper function that runs out of gas + */ + function wrappedOutOfGasCall(StandardRevertTestContract target) external { + emit WrapperCall("out_of_gas", false); + + // Consume most gas first + for (uint256 i = 0; i < 100000; i++) { + // Gas consuming operation + keccak256(abi.encode(i)); + } + + // Then try the expensive call + target.expensiveComputation(10000); + } + + /** + * @dev Fund wrapper contract + */ + receive() external payable {} +} \ No newline at end of file diff --git a/tests/solidity/suites/revert_cases/hardhat.config.js b/tests/solidity/suites/revert_cases/hardhat.config.js new file mode 100644 index 000000000..019d79017 --- /dev/null +++ b/tests/solidity/suites/revert_cases/hardhat.config.js @@ -0,0 +1,27 @@ +require("@nomicfoundation/hardhat-toolbox"); + +/** @type import('hardhat/config').HardhatUserConfig */ +module.exports = { + solidity: { + compilers: [ + { + version: "0.8.18", + }, + // This version is required to compile the werc9 contract. + { + version: "0.4.22", + }, + ], + }, + networks: { + cosmos: { + url: "http://127.0.0.1:8545", + chainId: 262144, + accounts: [ + "0x88CBEAD91AEE890D27BF06E003ADE3D4E952427E88F88D31D61D3EF5E5D54305", + "0x3B7955D25189C99A7468192FCBC6429205C158834053EBE3F78F4512AB432DB9", + "0xe9b1d63e8acd7fe676acb43afb390d4b0202dab61abec9cf2a561e4becb147de", + ], + }, + }, +}; diff --git a/tests/solidity/suites/revert_cases/package.json b/tests/solidity/suites/revert_cases/package.json new file mode 100644 index 000000000..7788ed08a --- /dev/null +++ b/tests/solidity/suites/revert_cases/package.json @@ -0,0 +1,35 @@ +{ + "name": "revert_cases", + "version": "1.0.0", + "author": "Evmos team", + "license": "GPL-3.0-or-later", + "scripts": { + "get-contracts": "mkdir -p ./contracts/cosmos && rsync -avm --include='*/' --exclude='**/ERC20Minter_OpenZeppelinV5.sol' --exclude='**/WEVMOS.sol' --exclude='**/ERC20NoMetadata.sol' --include='*.sol' --exclude='*' ../../../../precompiles/ ./contracts/cosmos/", + "clean-contracts": "rm -rf ./contracts/cosmos/*", + "test-ganache": "yarn hardhat test", + "test-cosmos": "yarn get-contracts && yarn hardhat test --network cosmos && yarn clean-contracts" + }, + "devDependencies": { + "@nomicfoundation/hardhat-chai-matchers": "^2.0.2", + "@nomicfoundation/hardhat-ethers": "^3.0.4", + "@nomicfoundation/hardhat-network-helpers": "^1.0.8", + "@nomicfoundation/hardhat-toolbox": "^3.0.0", + "@nomicfoundation/hardhat-verify": "^1.1.1", + "@openzeppelin/hardhat-upgrades": "^2.0.2", + "@openzeppelin/contracts": "^4.9.6", + "@typechain/ethers-v6": "^0.4.3", + "@typechain/hardhat": "^8.0.3", + "@types/chai": "^4.3.5", + "@types/mocha": "^10.0.1", + "chai": "^4.3.7", + "hardhat": "^2.20.0", + "hardhat-gas-reporter": "^1.0.9", + "solidity-coverage": "^0.8.4", + "ts-node": "^10.9.1", + "typechain": "^8.3.1", + "typescript": "^5.1.6" + }, + "dependencies": { + "ethers": "^6.7.0" + } +} diff --git a/tests/solidity/suites/revert_cases/test/common.js b/tests/solidity/suites/revert_cases/test/common.js new file mode 100644 index 000000000..5291c324f --- /dev/null +++ b/tests/solidity/suites/revert_cases/test/common.js @@ -0,0 +1,41 @@ +// Common constants and helper utilities for precompile tests + +const STAKING_PRECOMPILE_ADDRESS = '0x0000000000000000000000000000000000000800' +const BECH32_PRECOMPILE_ADDRESS = '0x0000000000000000000000000000000000000400' +const DISTRIBUTION_PRECOMPILE_ADDRESS = '0x0000000000000000000000000000000000000801' +const BANK_PRECOMPILE_ADDRESS = '0x0000000000000000000000000000000000000804' +const GOV_PRECOMPILE_ADDRESS = '0x0000000000000000000000000000000000000805' +const SLASHING_PRECOMPILE_ADDRESS = '0x0000000000000000000000000000000000000806' +const P256_PRECOMPILE_ADDRESS = '0x0000000000000000000000000000000000000100' +const WERC20_ADDRESS = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' + +// Default gas limits used across tests +const DEFAULT_GAS_LIMIT = 1_000_000 +const LARGE_GAS_LIMIT = 10_000_000 +const LOW_GAS_LIMIT=50_000 + +const PANIC_ASSERT_0x01 = "Panic(1)" +const PANIC_DIVISION_BY_ZERO_0x12 = "Panic(18)" +const PANIC_ARRAY_OUT_OF_BOUND_0x32 = "Panic(50)" + +module.exports = { + // Precompile Addresses + STAKING_PRECOMPILE_ADDRESS, + BECH32_PRECOMPILE_ADDRESS, + DISTRIBUTION_PRECOMPILE_ADDRESS, + BANK_PRECOMPILE_ADDRESS, + GOV_PRECOMPILE_ADDRESS, + SLASHING_PRECOMPILE_ADDRESS, + P256_PRECOMPILE_ADDRESS, + WERC20_ADDRESS, + + // Gas limits + DEFAULT_GAS_LIMIT, + LARGE_GAS_LIMIT, + LOW_GAS_LIMIT, + + // Panics + PANIC_ASSERT_0x01, + PANIC_DIVISION_BY_ZERO_0x12, + PANIC_ARRAY_OUT_OF_BOUND_0x32 +} \ No newline at end of file diff --git a/tests/solidity/suites/revert_cases/test/precompile_revert_cases.js b/tests/solidity/suites/revert_cases/test/precompile_revert_cases.js new file mode 100644 index 000000000..ce397d0ff --- /dev/null +++ b/tests/solidity/suites/revert_cases/test/precompile_revert_cases.js @@ -0,0 +1,239 @@ +const { expect } = require('chai'); +const hre = require('hardhat'); +const { LARGE_GAS_LIMIT, LOW_GAS_LIMIT } = require('./common'); +const { + decodeRevertReason, + analyzeFailedTransaction, + verifyTransactionRevert, + verifyOutOfGasError +} = require('./test_helper') + +describe('Precompile Revert Cases E2E Tests', function () { + let revertTestContract, precompileWrapper; + let validValidatorAddress, invalidValidatorAddress; + let analysis, decodedReason; + + before(async function () { + [signer] = await hre.ethers.getSigners(); + + // Deploy RevertTestContract + const RevertTestContractFactory = await hre.ethers.getContractFactory('RevertTestContract'); + revertTestContract = await RevertTestContractFactory.deploy({ + value: hre.ethers.parseEther('1.0'), // Fund with 1 ETH + gasLimit: LARGE_GAS_LIMIT + }); + await revertTestContract.waitForDeployment(); + + // Deploy PrecompileWrapper + const PrecompileWrapperFactory = await hre.ethers.getContractFactory('PrecompileWrapper'); + precompileWrapper = await PrecompileWrapperFactory.deploy({ + value: hre.ethers.parseEther('1.0'), // Fund with 1 ETH + gasLimit: LARGE_GAS_LIMIT + }); + await precompileWrapper.waitForDeployment(); + + // Use a known validator for valid cases and invalid one for error cases + validValidatorAddress = 'cosmosvaloper10jmp6sgh4cc6zt3e8gw05wavvejgr5pw4xyrql'; + invalidValidatorAddress = 'cosmosvaloper10jmp6sgh4cc6zt3e8gw05wavvejgr5pinvalid'; + + console.log('RevertTestContract deployed at:', await revertTestContract.getAddress()); + console.log('PrecompileWrapper deployed at:', await precompileWrapper.getAddress()); + + analysis = null; + decodedReason = null; + }); + + describe('Direct Precompile Call Reverts', function () { + it('should handle direct staking precompile revert', async function () { + try { + const tx = await revertTestContract.directStakingRevert(invalidValidatorAddress, { gasLimit: LARGE_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have reverted'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash) + } + verifyTransactionRevert(analysis, "invalid validator address") + }); + + it('should handle direct distribution precompile revert', async function () { + try { + const tx = await revertTestContract.directDistributionRevert(invalidValidatorAddress, { gasLimit: LARGE_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have reverted'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash) + } + verifyTransactionRevert(analysis, "invalid validator address") + }); + + it('should handle direct bank precompile revert', async function () { + // directBankRevert is a view function, so it should revert immediately + try { + await revertTestContract.directBankRevert(); + expect.fail('Call should have reverted'); + } catch (error) { + decodedReason = decodeRevertReason(error.data) + } + expect(decodedReason).contains("intended revert") + }); + + it('should capture precompile revert reason through transaction receipt', async function () { + try { + const tx = await revertTestContract.directStakingRevert(invalidValidatorAddress, { gasLimit: LARGE_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have reverted'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash) + } + verifyTransactionRevert(analysis, "invalid validator address") + }); + }); + + describe('Precompile Call Via Contract Reverts', function () { + it('should handle precompile call via contract revert', async function () { + try { + const tx = await revertTestContract.precompileViaContractRevert(invalidValidatorAddress, { gasLimit: LARGE_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have reverted'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash) + } + verifyTransactionRevert(analysis, "invalid validator address") + }); + + it('should handle multiple precompile calls with revert', async function () { + try { + const tx = await revertTestContract.multiplePrecompileCallsWithRevert(invalidValidatorAddress, { gasLimit: LARGE_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have reverted'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash) + } + verifyTransactionRevert(analysis, "invalid validator address") + }); + + it('should handle wrapper contract precompile revert', async function () { + try { + const tx = await precompileWrapper.wrappedStakingCall(invalidValidatorAddress, 1, { gasLimit: LARGE_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have reverted'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash) + } + verifyTransactionRevert(analysis, "invalid validator address") + }); + + it('should capture wrapper revert reason via transaction receipt', async function () { + try { + const tx = await precompileWrapper.wrappedDistributionCall(invalidValidatorAddress, { gasLimit: LARGE_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have reverted'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash) + } + verifyTransactionRevert(analysis, "invalid validator address") + }); + }); + + describe('Precompile OutOfGas Error Cases', function () { + it('should handle direct precompile OutOfGas', async function () { + // Use a very low gas limit to trigger OutOfGas on precompile calls + try { + const tx = await revertTestContract.directStakingOutOfGas(validValidatorAddress, { gasLimit: LOW_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have failed with OutOfGas'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash) + } + verifyOutOfGasError(analysis) + }); + + it('should handle precompile via contract OutOfGas', async function () { + try { + const tx = await revertTestContract.precompileViaContractOutOfGas(validValidatorAddress, { gasLimit: LOW_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have failed with OutOfGas'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash) + } + verifyOutOfGasError(analysis) + }); + + it('should handle wrapper precompile OutOfGas', async function () { + try { + const tx = await precompileWrapper.wrappedOutOfGasCall(validValidatorAddress, { gasLimit: LOW_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have failed with OutOfGas'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash) + } + verifyOutOfGasError(analysis) + }); + + it('should analyze precompile OutOfGas error through transaction receipt', async function () { + try { + const tx = await revertTestContract.directStakingOutOfGas(validValidatorAddress, { gasLimit: LOW_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have failed with OutOfGas'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash); + } + verifyOutOfGasError(analysis) + }); + }); + + describe('Comprehensive Precompile Error Analysis', function () { + it('should properly decode various precompile error types from transaction receipts', async function () { + const testCases = [ + { + name: 'Staking Precompile Revert', + call: () => revertTestContract.directStakingRevert(invalidValidatorAddress, { gasLimit: LARGE_GAS_LIMIT }), + expectedReason: "invalid validator address" + }, + { + name: 'Distribution Precompile Revert', + call: () => revertTestContract.directDistributionRevert(invalidValidatorAddress, { gasLimit: LARGE_GAS_LIMIT }), + expectedReason: "invalid validator address" + } + ]; + + for (const testCase of testCases) { + try { + const tx = await testCase.call(); + await tx.wait() + expect.fail(`${testCase.name} should have reverted`); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash); + } + verifyTransactionRevert(analysis, testCase.expectedReason) + } + }); + + it('should verify precompile error data is properly hex-encoded in receipts', async function () { + try { + const tx = await revertTestContract.directStakingRevert(invalidValidatorAddress, { gasLimit: LARGE_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have reverted'); + } catch (error) { + if (error.receipt) { + // Simulate the call to get error data + try { + const contractAddress = await revertTestContract.getAddress(); + await hre.ethers.provider.call({ + to: contractAddress, + data: revertTestContract.interface.encodeFunctionData('directStakingRevert', [invalidValidatorAddress]), + gasLimit: LARGE_GAS_LIMIT + }); + } catch (callError) { + expect(callError.data).to.match(/^0x/); // Should be hex-encoded + console.log('Precompile error data (hex):', callError.data); + + const decoded = decodeRevertReason(callError.data); + expect(decoded).to.include("invalid validator address"); + console.log('Decoded precompile reason:', decoded); + } + } + } + }); + }); +}); \ No newline at end of file diff --git a/tests/solidity/suites/revert_cases/test/standard_revert_cases.js b/tests/solidity/suites/revert_cases/test/standard_revert_cases.js new file mode 100644 index 000000000..87e1e608f --- /dev/null +++ b/tests/solidity/suites/revert_cases/test/standard_revert_cases.js @@ -0,0 +1,374 @@ +const { expect } = require('chai'); +const hre = require('hardhat'); +const { + DEFAULT_GAS_LIMIT, + LARGE_GAS_LIMIT, + LOW_GAS_LIMIT, + PANIC_ASSERT_0x01, + PANIC_DIVISION_BY_ZERO_0x12, + PANIC_ARRAY_OUT_OF_BOUND_0x32 +} = require('./common'); +const { + decodeRevertReason, + analyzeFailedTransaction, + verifyTransactionRevert, + verifyOutOfGasError +} = require('./test_helper') + + +describe('Standard Revert Cases E2E Tests', function () { + let standardRevertTestContract, simpleWrapper, signer; + let analysis, decodedReason; + + before(async function () { + [signer] = await hre.ethers.getSigners(); + + // Deploy StandardRevertTestContract + const StandardRevertTestContractFactory = await hre.ethers.getContractFactory('StandardRevertTestContract'); + standardRevertTestContract = await StandardRevertTestContractFactory.deploy({ + value: hre.ethers.parseEther('1.0'), // Fund with 1 ETH + gasLimit: LARGE_GAS_LIMIT + }); + await standardRevertTestContract.waitForDeployment(); + + // Deploy SimpleWrapper + const SimpleWrapperFactory = await hre.ethers.getContractFactory('SimpleWrapper'); + simpleWrapper = await SimpleWrapperFactory.deploy({ + value: hre.ethers.parseEther('1.0'), // Fund with 1 ETH + gasLimit: LARGE_GAS_LIMIT + }); + await simpleWrapper.waitForDeployment(); + + // Verify successful deployment + const contractAddress = await standardRevertTestContract.getAddress(); + const wrapperAddress = await simpleWrapper.getAddress(); + console.log('StandardRevertTestContract deployed at:', contractAddress); + console.log('SimpleWrapper deployed at:', wrapperAddress); + + analysis = null; + decodedReason = null; + }); + + describe('Standard Contract Call Reverts', function () { + it('should handle standard revert with custom message', async function () { + const customMessage = "Custom revert message"; + try { + const tx = await standardRevertTestContract.standardRevert(customMessage, { gasLimit: DEFAULT_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have reverted'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash); + } + verifyTransactionRevert(analysis, customMessage); + + // Verify we can capture the revert reason via static call + try { + await standardRevertTestContract.standardRevert.staticCall(customMessage); + expect.fail('Static call should have reverted'); + } catch (error) { + decodedReason = decodeRevertReason(error.data); + } + expect(decodedReason).to.include(customMessage); + }); + + it('should handle require revert with proper error message', async function () { + const value = 100; + const threshold = 50; + + try { + const tx = await standardRevertTestContract.requireRevert(value, threshold, { gasLimit: DEFAULT_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have reverted'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash); + } + verifyTransactionRevert(analysis, "Value exceeds threshold"); + + // Verify we can capture the revert reason via static call + try { + await standardRevertTestContract.requireRevert.staticCall(value, threshold); + expect.fail('Static call should have reverted'); + } catch (error) { + decodedReason = decodeRevertReason(error.data); + } + expect(decodedReason).to.include("Value exceeds threshold"); + + // Verify successful case (no revert when value < threshold) + const successTx = await standardRevertTestContract.requireRevert(25, 50, { gasLimit: DEFAULT_GAS_LIMIT }); + const receipt = await successTx.wait(); + expect(receipt.status).to.equal(1, 'Transaction should succeed when value < threshold'); + }); + + it('should handle assert revert (Panic error)', async function () { + try { + const tx = await standardRevertTestContract.assertRevert({ gasLimit: DEFAULT_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have reverted'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash); + } + verifyTransactionRevert(analysis, PANIC_ASSERT_0x01); + + // Verify we can capture the revert reason via static call + try { + await standardRevertTestContract.assertRevert.staticCall(); + expect.fail('Static call should have reverted'); + } catch (error) { + decodedReason = decodeRevertReason(error.data); + } + expect(decodedReason).to.include(PANIC_ASSERT_0x01); + }); + + it('should handle division by zero (View Panic error)', async function () { + try { + await standardRevertTestContract.divisionByZero(); + expect.fail('View call should have reverted'); + } catch (error) { + decodedReason = decodeRevertReason(error.data); + } + expect(decodedReason).to.include(PANIC_DIVISION_BY_ZERO_0x12); + }); + + it('should handle division by zero (Transaction Panic error)', async function () { + try { + const tx = await standardRevertTestContract.divisionByZeroTx({ gasLimit: DEFAULT_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have reverted'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash); + } + verifyTransactionRevert(analysis, PANIC_DIVISION_BY_ZERO_0x12); + }); + + it('should handle array out of bounds (View Panic error)', async function () { + try { + await standardRevertTestContract.arrayOutOfBounds(); + expect.fail('View call should have reverted'); + } catch (error) { + decodedReason = decodeRevertReason(error.data); + } + expect(decodedReason).contains(PANIC_ARRAY_OUT_OF_BOUND_0x32); + }); + + it('should handle array out of bounds (Transaction Panic error)', async function () { + try { + const tx = await standardRevertTestContract.arrayOutOfBoundsTx({ gasLimit: DEFAULT_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have reverted'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash); + } + verifyTransactionRevert(analysis, PANIC_ARRAY_OUT_OF_BOUND_0x32); + }); + + it('should capture revert reason through eth_getTransactionReceipt', async function () { + try { + const tx = await standardRevertTestContract.standardRevert("Test message", { gasLimit: DEFAULT_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have reverted'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash); + } + verifyTransactionRevert(analysis, "Test message"); + }); + }); + + describe('Complex Revert Scenarios', function () { + it('should handle multiple calls with revert', async function () { + try { + const tx = await standardRevertTestContract.multipleCallsWithRevert({ gasLimit: DEFAULT_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have reverted'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash); + } + verifyTransactionRevert(analysis, "Multiple calls revert"); + }); + + it('should handle try-catch revert scenario', async function () { + try { + const tx = await standardRevertTestContract.tryCatchRevert(true, { gasLimit: DEFAULT_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have reverted'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash); + } + verifyTransactionRevert(analysis, "Internal function revert"); + }); + + it('should handle wrapper contract revert', async function () { + const contractAddress = await standardRevertTestContract.getAddress(); + try { + const tx = await simpleWrapper.wrappedStandardCall(contractAddress, "Wrapper test", { gasLimit: DEFAULT_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have reverted'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash); + } + verifyTransactionRevert(analysis, "Wrapper test"); + }); + }); + + describe('OutOfGas Error Cases', function () { + it('should handle standard contract OutOfGas', async function () { + try { + const tx = await standardRevertTestContract.standardOutOfGas({ gasLimit: LOW_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have failed with OutOfGas'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash); + } + verifyOutOfGasError(analysis); + }); + + it('should handle expensive computation OutOfGas', async function () { + try { + const tx = await standardRevertTestContract.expensiveComputation(10000, { gasLimit: LOW_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have failed with OutOfGas'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash); + } + verifyOutOfGasError(analysis); + }); + + it('should handle expensive storage OutOfGas', async function () { + try { + const tx = await standardRevertTestContract.expensiveStorage(100, { gasLimit: LOW_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have failed with OutOfGas'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash); + } + verifyOutOfGasError(analysis); + }); + + it('should handle wrapper OutOfGas', async function () { + const contractAddress = await standardRevertTestContract.getAddress(); + try { + const tx = await simpleWrapper.wrappedOutOfGasCall(contractAddress, { gasLimit: LOW_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have failed with OutOfGas'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash); + } + verifyOutOfGasError(analysis); + }); + + it('should analyze OutOfGas error through transaction receipt', async function () { + try { + const tx = await standardRevertTestContract.standardOutOfGas({ gasLimit: LOW_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have failed with OutOfGas'); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash); + } + verifyOutOfGasError(analysis); + }); + }); + + describe('Comprehensive Error Analysis', function () { + it('should properly decode various error types from transaction receipts', async function () { + // Transaction-based functions that create receipts + const transactionTestCases = [ + { + name: 'Standard Revert', + call: async () => { + const tx = await standardRevertTestContract.standardRevert("Standard error", { gasLimit: DEFAULT_GAS_LIMIT }); + await tx.wait(); + }, + expectedReason: "Standard error" + }, + { + name: 'Require Revert', + call: async () => { + const tx = await standardRevertTestContract.requireRevert(100, 50, { gasLimit: DEFAULT_GAS_LIMIT }); + await tx.wait(); + }, + expectedReason: "Value exceeds threshold" + }, + { + name: 'Assert Revert', + call: async () => { + const tx = await standardRevertTestContract.assertRevert({ gasLimit: DEFAULT_GAS_LIMIT }); + await tx.wait(); + }, + expectedReason: PANIC_ASSERT_0x01 + }, + { + name: 'Division by Zero (Transaction)', + call: async () => { + const tx = await standardRevertTestContract.divisionByZeroTx({ gasLimit: DEFAULT_GAS_LIMIT }); + await tx.wait(); + }, + expectedReason: PANIC_DIVISION_BY_ZERO_0x12 + }, + { + name: 'Array Out of Bounds (Transaction)', + call: async () => { + const tx = await standardRevertTestContract.arrayOutOfBoundsTx({ gasLimit: DEFAULT_GAS_LIMIT }); + await tx.wait(); + }, + expectedReason: PANIC_ARRAY_OUT_OF_BOUND_0x32 + } + ]; + + // View functions that don't create receipts but still revert + const viewTestCases = [ + { + name: 'Division by Zero (View)', + call: async () => await standardRevertTestContract.divisionByZero(), + expectedReason: PANIC_DIVISION_BY_ZERO_0x12 + }, + { + name: 'Array Out of Bounds (View)', + call: async () => await standardRevertTestContract.arrayOutOfBounds(), + expectedReason: PANIC_ARRAY_OUT_OF_BOUND_0x32 + } + ]; + + // Test transaction-based functions + for (const testCase of transactionTestCases) { + try { + await testCase.call(); + expect.fail(`${testCase.name} should have reverted`); + } catch (error) { + analysis = await analyzeFailedTransaction(error.receipt.hash); + } + verifyTransactionRevert(analysis, testCase.expectedReason); + } + + // Test view functions (no receipts) + for (const testCase of viewTestCases) { + try { + await testCase.call(); + expect.fail(`${testCase.name} should have reverted`); + } catch (error) { + decodedReason = decodeRevertReason(error.data); + } + expect(decodedReason).contains(testCase.expectedReason); + } + }); + + it('should verify error data is properly hex-encoded in receipts', async function () { + try { + const tx = await standardRevertTestContract.standardRevert("Hex encoding test", { gasLimit: DEFAULT_GAS_LIMIT }); + await tx.wait(); + expect.fail('Transaction should have reverted'); + } catch (error) { + try { + const contractAddress = await standardRevertTestContract.getAddress(); + await hre.ethers.provider.call({ + to: contractAddress, + data: standardRevertTestContract.interface.encodeFunctionData('standardRevert', ['Hex encoding test']), + gasLimit: DEFAULT_GAS_LIMIT + }); + expect.fail('Call should have reverted'); + } catch (error) { + decodedReason = await decodeRevertReason(error.data); + } + expect(decodedReason).to.include('Hex encoding test'); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/solidity/suites/revert_cases/test/test_helper.js b/tests/solidity/suites/revert_cases/test/test_helper.js new file mode 100644 index 000000000..258bb2ac3 --- /dev/null +++ b/tests/solidity/suites/revert_cases/test/test_helper.js @@ -0,0 +1,144 @@ +const { expect } = require('chai'); + +// Helper to convert the raw tuple returned by staking.validator() into an object +function parseValidator (raw) { + return { + operatorAddress: raw[0], + consensusPubkey: raw[1], + jailed: raw[2], + status: raw[3], + tokens: raw[4], + delegatorShares: raw[5], + description: raw[6], + unbondingHeight: raw[7], + unbondingTime: raw[8], + commission: raw[9], + minSelfDelegation: raw[10] + } +} + +// Utility to parse logs and return the first matching event by name +function findEvent (logs, iface, eventName) { + for (const log of logs) { + try { + const parsed = iface.parseLog(log) + if (parsed && parsed.name === eventName) { + return parsed + } + } catch { + // ignore logs that do not match the contract interface + } + } + return null +} + +/** + * Helper function to decode hex error data from transaction receipt + */ +function decodeRevertReason(errorData) { + if (!errorData || errorData === '0x') { + return null; + } + + try { + // Remove '0x' prefix + const cleanHex = errorData.startsWith('0x') ? errorData.slice(2) : errorData; + + // Check if it's a standard revert string (function selector: 08c379a0) + if (cleanHex.startsWith('08c379a0')) { + const reasonHex = cleanHex.slice(8); // Remove function selector + const offsetHex = reasonHex.slice(0, 64); // Get offset (should be 0x20 = 32) + const offset = parseInt(offsetHex, 16); + + if (offset === 32) { // Standard ABI encoding has offset of 32 + const reasonLength = parseInt(reasonHex.slice(64, 128), 16); // Get string length from next 32 bytes + const reasonBytes = reasonHex.slice(128, 128 + reasonLength * 2); // Get string data + return Buffer.from(reasonBytes, 'hex').toString('utf8'); + } else { + // Fallback for non-standard encoding + const reasonLength = parseInt(reasonHex.slice(0, 64), 16); // Get string length + const reasonBytes = reasonHex.slice(128, 128 + reasonLength * 2); // Get string data + return Buffer.from(reasonBytes, 'hex').toString('utf8'); + } + } + + // Check if it's a Panic error (function selector: 4e487b71) + if (cleanHex.startsWith('4e487b71')) { + const panicCode = parseInt(cleanHex.slice(8, 72), 16); + return `Panic(${panicCode})`; + } + + // Return raw hex if not a standard format + return `Raw: ${errorData}`; + } catch (error) { + return `Decode error: ${error.message}`; + } +} + +/** + * Helper function to analyze transaction receipt for revert information + */ +async function analyzeFailedTransaction(txHash) { + const receipt = await hre.ethers.provider.getTransactionReceipt(txHash); + const tx = await hre.ethers.provider.getTransaction(txHash); + + // Try to get revert reason through call simulation + try { + await hre.ethers.provider.call({ + to: tx.to, + data: tx.data, + from: tx.from, + value: tx.value, + gasLimit: tx.gasLimit, + gasPrice: tx.gasPrice + }); + } catch (error) { + console.log(` Revert Reason: ${decodeRevertReason(error.data)}`); + return { + status: receipt.status, + gasUsed: receipt.gasUsed, + gasLimit: tx.gasLimit, + errorData: error.data, + decodedReason: decodeRevertReason(error.data), + errorMessage: error.message + }; + } + + return { + status: receipt.status, + gasUsed: receipt.gasUsed, + gasLimit: tx.gasLimit, + errorData: null, + decodedReason: null, + errorMessage: null + }; +} + +/** + * Helper function to verify decoded revert reason + */ +function verifyTransactionRevert(analysis, expectedRevertReason) { + expect(analysis).to.not.be.null; + expect(analysis.status).to.equal(0); // Failed transaction + expect(analysis.errorData).to.not.be.null; + expect(analysis.decodedReason).contains(expectedRevertReason, "unexpected revert reason"); +} + +/** + * Helper function to verify out of gas error + */ +function verifyOutOfGasError(analysis) { + expect(analysis).to.not.be.null; + expect(analysis.status).to.equal(0); // Failed transaction + expect(analysis.gasUsed).to.be.equal(analysis.gasLimit); + expect(analysis.errorMessage.toLowerCase()).include('out of gas'); +} + +module.exports = { + parseValidator, + findEvent, + decodeRevertReason, + analyzeFailedTransaction, + verifyTransactionRevert, + verifyOutOfGasError +} \ No newline at end of file