diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml index d32f092..68122bc 100644 --- a/.github/workflows/regression.yml +++ b/.github/workflows/regression.yml @@ -43,6 +43,19 @@ jobs: --allow-source-diff 0x6F5c0A5a824773E8f8285bC5aA59ea0Aab2A6400 --allow-source-diff 0xaf35A63a4114B7481589fDD9FDB3e35Fd65fAed7 --allow-source-diff 0x647DeE7bF33e44829e6430F7A08F63b3319694f0 + - config: config_samples/ethereum/mainnet/gualGovernance/dual_governance_mainnet_config.json + hardhat: hardhat_configs/mainnet_hardhat_config.ts + network: mainnet + flags: > + --allow-bytecode-diff 0xd6A67636c05BeB5B4a5c90D408b03A63c4e39426 + - config: config_samples/ethereum/mainnet/gualGovernance/dual_governance_mainnet_voting_config.json + hardhat: hardhat_configs/mainnet_hardhat_config.ts + network: mainnet + flags: "" + - config: config_samples/ethereum/mainnet/gualGovernance/dual_governance_mainnet_upgrade_voting_config.json + hardhat: hardhat_configs/mainnet_hardhat_config.ts + network: mainnet + flags: "" - config: config_samples/ethereum/mainnet/tw/tw_config.json hardhat: hardhat_configs/mainnet_hardhat_config.ts network: mainnet @@ -67,6 +80,9 @@ jobs: --allow-bytecode-diff 0x7E99eE3C66636DE415D2d7C880938F2f40f94De4 --allow-bytecode-diff 0x2F0303F20E0795E6CCd17BD5efE791A586f28E03 --allow-bytecode-diff 0x6d1a9bBFF97f7565e9532FEB7b499982848E5e07 + --allow-bytecode-diff 0x933b84D2C01B04C2f53cD2FB1b7055241E122C83 + --allow-bytecode-diff 0xE22486EA7cE77daE718fFa7B7114fD50CF73Cbac + --allow-bytecode-diff 0xd253b0ca059343e70474e685Beb2974F10CCFa67 - config: config_samples/ethereum/hoodi/vaults/hoodi_vaults_easy_track_config.json hardhat: hardhat_configs/hoodi_hardhat_config.ts network: hoodi @@ -83,6 +99,7 @@ jobs: --allow-source-diff 0xc5dCd2A9642ceA9B71A632BF5b8ff52424Ea1B40 env: ETHERSCAN_EXPLORER_TOKEN: ${{ secrets.ETHERSCAN_EXPLORER_TOKEN }} + ETHERSCAN_TOKEN: ${{ secrets.ETHERSCAN_EXPLORER_TOKEN }} GITHUB_API_TOKEN: ${{ github.token }} LOCAL_RPC_URL: "http://127.0.0.1:7545" REMOTE_RPC_URL: ${{ matrix.network == 'mainnet' && secrets['REMOTE_RPC_URL_MAINNET'] || secrets['REMOTE_RPC_URL_HOODI'] }} diff --git a/config_samples/ethereum/hoodi/vaults/hoodi_vaults_testnet_config.json b/config_samples/ethereum/hoodi/vaults/hoodi_vaults_testnet_config.json index ef883a3..d093566 100644 --- a/config_samples/ethereum/hoodi/vaults/hoodi_vaults_testnet_config.json +++ b/config_samples/ethereum/hoodi/vaults/hoodi_vaults_testnet_config.json @@ -382,15 +382,27 @@ "0xdc79D1751D1435fEc9204c03ca3D64ceEB73A7df", "0x7b2B6EA1e53B2039a493cA587805183883Cb8B88", "0x2291496c76CC2e9368DbE9d4977ED2623cbDfb32", - "0xBC2bb8310730F3D2b514Cb26f7e0A8776De879Ac", "0xA48DF029Fd2e5FCECB3886c5c2F60e3625A1E87d", "0x0534aA41907c9631fae990960bCC72d75fA7cfeD", "0xd3545AC0286A94970BacC41D3AF676b89606204F", "0xe2EF9536DAAAEBFf5b1c130957AB3E80056b06D8", "0x49B3512c44891bef83F8967d075121Bd1b07a01B", "0x9CAaCCc62c66d817CC59c44780D1b722359795bF", - "0x78780e70Eae33e2935814a327f7dB6c01136cc62" - ] + "0x78780e70Eae33e2935814a327f7dB6c01136cc62", + "0x284D91a7D47850d21A6DEaaC6E538AC7E5E6fc2a", + "0xBC2bb8310730F3D2b514Cb26f7e0A8776De879Ac", + "0xF21f98cac0Ba38f02b4d5be1667cc345929E8877", + "0xfEF8B796Fea42b3C68E342364Adcf88F1d6145a6", + "0x4DF806111AC58e93d90E6D2fBE8522a76be6F499", + "0x56Ff87F41a8CF795764E15E496124240Ac17695b", + "0xc3FA83D65a900303e1d99cDBBF762c6630562c04", + "0x351426775c75aB5127de860Cdcaf1953F1D622a2", + "0x83DfE5Fe8ac8b7DB38c020F4F54BF09b65D92c63", + "0xc5dCd2A9642ceA9B71A632BF5b8ff52424Ea1B40", + "0xa11906bBBBaC5207b8FDA4F7F294d7EcB8dcc758" + ], + 1765324800, + 300 ], "0x3e144aEd003b5AE6953A99B78dD34154CF3F8c76": [ "0xb3e6a8B6A752d3bb905A1B3Ef12bbdeE77E8160e", diff --git a/config_samples/ethereum/mainnet/gualGovernance/dual_governance_mainnet_config.json b/config_samples/ethereum/mainnet/gualGovernance/dual_governance_mainnet_config.json index a8a245b..2d0169b 100644 --- a/config_samples/ethereum/mainnet/gualGovernance/dual_governance_mainnet_config.json +++ b/config_samples/ethereum/mainnet/gualGovernance/dual_governance_mainnet_config.json @@ -15,6 +15,8 @@ "0x553337946F2FAb8911774b20025fa776B76a7CcE": "TimelockedGovernance" }, "explorer_hostname": "api.etherscan.io", + "explorer_token_env_var": "ETHERSCAN_EXPLORER_TOKEN", + "explorer_chain_id": 1, "github_repo": { "url": "https://github.com/lidofinance/dual-governance", "commit": "14b59968c98992bf51a6f1340457127ddc0cc6c2", diff --git a/config_samples/ethereum/mainnet/gualGovernance/dual_governance_mainnet_upgrade_voting_config.json b/config_samples/ethereum/mainnet/gualGovernance/dual_governance_mainnet_upgrade_voting_config.json index 3f93b85..9c39fcc 100644 --- a/config_samples/ethereum/mainnet/gualGovernance/dual_governance_mainnet_upgrade_voting_config.json +++ b/config_samples/ethereum/mainnet/gualGovernance/dual_governance_mainnet_upgrade_voting_config.json @@ -4,6 +4,8 @@ "0x67988077f29FbA661911d9567E05cc52C51ca1B0": "DGUpgradeOmnibusMainnet" }, "explorer_hostname": "api.etherscan.io", + "explorer_token_env_var": "ETHERSCAN_EXPLORER_TOKEN", + "explorer_chain_id": 1, "github_repo": { "url": "https://github.com/lidofinance/dual-governance", "commit": "959e1766a1a27eeb5ca57dc05e4f5cae1e025f47", diff --git a/config_samples/ethereum/mainnet/gualGovernance/dual_governance_mainnet_voting_config.json b/config_samples/ethereum/mainnet/gualGovernance/dual_governance_mainnet_voting_config.json index 9f27a2b..13c5d60 100644 --- a/config_samples/ethereum/mainnet/gualGovernance/dual_governance_mainnet_voting_config.json +++ b/config_samples/ethereum/mainnet/gualGovernance/dual_governance_mainnet_voting_config.json @@ -6,6 +6,8 @@ "0x2a30F5aC03187674553024296bed35Aa49749DDa": "TimeConstraints" }, "explorer_hostname": "api.etherscan.io", + "explorer_token_env_var": "ETHERSCAN_EXPLORER_TOKEN", + "explorer_chain_id": 1, "github_repo": { "url": "https://github.com/lidofinance/dual-governance", "commit": "f3f26383ee130d6a8bb0d1f420151e4a311018eb", diff --git a/diffyscan/diffyscan.py b/diffyscan/diffyscan.py index ef102cf..5a1dd79 100644 --- a/diffyscan/diffyscan.py +++ b/diffyscan/diffyscan.py @@ -40,16 +40,61 @@ ExceptionHandler, BaseCustomException, BinVerifierError, + CompileError, ) __version__ = "0.0.0" +def _build_github_solc_input( + contract_source_code, + config, + github_api_token, + recursive_parsing, + cache_github, +): + solc_input = contract_source_code["solcInput"] + sources = solc_input.get("sources", solc_input) + updated_sources = {} + missing = [] + + for path_to_file, source in sources.items(): + repo, dep_name = resolve_dep(path_to_file, config) + if not dep_name: + repo = config["github_repo"] + + if recursive_parsing: + github_file = get_file_from_github_recursive( + github_api_token, repo, path_to_file, dep_name, cache_github + ) + else: + github_file = get_file_from_github( + github_api_token, repo, path_to_file, dep_name, cache_github + ) + + if not github_file: + missing.append(path_to_file) + continue + + updated_sources[path_to_file] = {"content": github_file} + + if "sources" in solc_input: + github_solc_input = dict(solc_input) + github_solc_input["sources"] = updated_sources + else: + github_solc_input = {"sources": updated_sources} + + return github_solc_input, missing + + def run_bytecode_diff( contract_address_from_config, contract_name_from_config, contract_source_code, config, + github_api_token, + recursive_parsing, + cache_github, deployer_account, local_rpc_url, remote_rpc_url, @@ -66,9 +111,29 @@ def run_bytecode_diff( # Get libraries from config if they exist libraries = config.get("bytecode_comparison", {}).get("libraries", None) + github_solc_input, missing_sources = _build_github_solc_input( + contract_source_code, + config, + github_api_token, + recursive_parsing, + cache_github, + ) + if missing_sources: + missing_preview = ", ".join(missing_sources[:5]) + more = "" + if len(missing_sources) > 5: + more = f" (and {len(missing_sources) - 5} more)" + raise CompileError( + "missing GitHub sources for bytecode compilation; " + f"count={len(missing_sources)}; first={missing_preview}{more}" + ) + + github_contract_source = dict(contract_source_code) + github_contract_source["solcInput"] = github_solc_input target_compiled_contract = compile_contract_from_explorer( - contract_source_code, libraries + github_contract_source, libraries ) + logger.okay("Compiled contract for bytecode comparison using GitHub sources") contract_creation_code, local_compiled_bytecode, immutables = ( parse_compiled_contract(target_compiled_contract) @@ -170,7 +235,10 @@ def run_source_diff( logger.okay("Files", files_count) if not skip_user_input: - input("Press Enter to proceed...") + if sys.stdin.isatty(): + input("Press Enter to proceed...") + else: + logger.info("Skipping prompt (non-interactive stdin).") logger.divider() logger.info("Diffing...") @@ -439,36 +507,40 @@ def process_config( source_stats.append(source_result) if enable_binary_comparison: - bytecode_match = run_bytecode_diff( - contract_address, - contract_name, - contract_code, - config, - deployer_account, - local_rpc_url, - remote_rpc_url, - ) - bytecode_stats.append( - { - "contract_address": contract_address, - "contract_name": contract_name, - "match": bytecode_match, - } - ) + try: + bytecode_match = run_bytecode_diff( + contract_address, + contract_name, + contract_code, + config, + github_api_token, + recursive_parsing, + cache_github, + deployer_account, + local_rpc_url, + remote_rpc_url, + ) + bytecode_stats.append( + { + "contract_address": contract_address, + "contract_name": contract_name, + "match": bytecode_match, + } + ) + except BaseCustomException as exc: + # Treat bytecode comparison errors as reportable diffs; final + # allowlist handling happens after all contracts are processed. + logger.error(str(exc)) + bytecode_stats.append( + { + "contract_address": contract_address, + "contract_name": contract_name, + "match": False, + } + ) except BaseCustomException as custom_exc: ExceptionHandler.raise_exception_or_log(custom_exc) traceback.print_exc() - # Track failed bytecode comparison if it was a BinVerifierError - if enable_binary_comparison and isinstance( - custom_exc, BinVerifierError - ): - bytecode_stats.append( - { - "contract_address": contract_address, - "contract_name": contract_name, - "match": False, - } - ) except KeyboardInterrupt: logger.info("Keyboard interrupt by user") diff --git a/diffyscan/utils/binary_verifier.py b/diffyscan/utils/binary_verifier.py index a058265..a2f0ac5 100644 --- a/diffyscan/utils/binary_verifier.py +++ b/diffyscan/utils/binary_verifier.py @@ -143,14 +143,15 @@ def _get_checkpoints_for_display( """ checkpoints = {0, *mismatches} - # Include last lines if instructions differ in count - if actual_instructions: - checkpoints.add(len(actual_instructions) - 1) - if expected_instructions: - checkpoints.add(len(expected_instructions) - 1) + # Bound checkpoints to the shared instruction range + max_idx = min(len(actual_instructions), len(expected_instructions)) - 1 + if max_idx < 0: + return [0] + + # Include last comparable line for context + checkpoints.add(max_idx) # Expand around mismatches - max_idx = min(len(actual_instructions), len(expected_instructions)) - 1 for idx in list(checkpoints): start_idx = max(0, idx - context_lines) end_idx = min(idx + context_lines, max_idx) @@ -198,6 +199,9 @@ def _format_instruction_diff(actual, expected, immutables): params = bgRed(actual_params) + " " + bgGreen(expected_params) is_immutable_only = False + if not same_opcode: + is_immutable_only = False + return (opcode, opname, params), is_immutable_only @@ -272,7 +276,8 @@ def deep_match_bytecode( logger.warn(f"Detected unknown opcodes: {unknown_opcodes}") # Check length differences - if len(actual_instructions) != len(expected_instructions): + length_mismatch = len(actual_instructions) != len(expected_instructions) + if length_mismatch: logger.warn("Codes have a different length") # Validate string literals @@ -291,6 +296,12 @@ def deep_match_bytecode( logger.okay("Bytecodes match (after trimming metadata and string literals)") return True + # If one side has no instructions, avoid diff rendering/index errors + if length_mismatch and (not actual_instructions or not expected_instructions): + raise BinVerifierError( + "Bytecodes have different length after trimming metadata and string literals" + ) + # Display diff with context checkpoints = _get_checkpoints_for_display( mismatches, actual_instructions, expected_instructions @@ -302,6 +313,12 @@ def deep_match_bytecode( zipped_instructions, checkpoints, immutables ) + # If lengths differ, this is a bytecode mismatch (not just immutables) + if length_mismatch: + raise BinVerifierError( + "Bytecodes have different length after trimming metadata and string literals" + ) + # If we found any mismatch outside immutables => fail if not is_matched_with_excluded_immutables: raise BinVerifierError( diff --git a/tests/test_binary_verifier.py b/tests/test_binary_verifier.py new file mode 100644 index 0000000..90d75bb --- /dev/null +++ b/tests/test_binary_verifier.py @@ -0,0 +1,27 @@ +import pytest + +from diffyscan.utils.binary_verifier import deep_match_bytecode +from diffyscan.utils.custom_exceptions import BinVerifierError + + +def test_length_mismatch_raises(): + actual = "0x6001600055fe" + expected = "0x6001600055fe6001" + + with pytest.raises(BinVerifierError, match="different length"): + deep_match_bytecode(actual, expected, immutables={}) + + +def test_immutable_only_diff_returns_false(): + actual = "0x6001fe" + expected = "0x6002fe" + + assert deep_match_bytecode(actual, expected, immutables={1: 1}) is False + + +def test_non_immutable_diff_raises(): + actual = "0x6001fe" + expected = "0x6001fd" + + with pytest.raises(BinVerifierError, match="differences not on the immutable"): + deep_match_bytecode(actual, expected, immutables={})