diff --git a/foundry.toml b/foundry.toml index 7de62135..8a492487 100644 --- a/foundry.toml +++ b/foundry.toml @@ -6,8 +6,15 @@ fs_permissions = [ { access = "read", path = "./lib/dss-test/script/input/"}, { access = "read", path = "./out/ArbitrumDomain.sol/ArbSysOverride.json"} ] +solc_version = "0.8.16" +evm_version = "cancun" +optimizer = true +optimizer_runs = 200 +via_ir = false +libraries = [ + "./lib/dss-exec-lib/src/DssExecLib.sol:DssExecLib:0x8De6DDbCd5053d32292AAA0D2105A32d108484a6" +] [rpc_endpoints] mainnet = "${ETH_RPC_URL}" -# See more config options https://github.com/gakonst/foundry/tree/master/config diff --git a/scripts/check-deployed-dssspell.sh b/scripts/check-deployed-dssspell.sh index 5ee2d3dd..d94918da 100755 --- a/scripts/check-deployed-dssspell.sh +++ b/scripts/check-deployed-dssspell.sh @@ -77,8 +77,8 @@ fi # Check verified spell linked library library_address=$(echo "$verified_spell_info" | jq -r '.Library | split(":") | .[1]') checksum_library_address=$(cast --to-checksum-address "$library_address") -if [ "$checksum_library_address" == "$(cat DssExecLib.address)" ]; then - success_check "DssSpell library matches hardcoded address in DssExecLib.address." +if [ "$checksum_library_address" == "$(cat foundry.toml | sed -nE 's/.*DssExecLib:(0x[0-9a-fA-F]{40}).*/\1/p')" ]; then + success_check "DssSpell library matches hardcoded address in foundry.toml." else error_check "DssSpell library does not match hardcoded address." fi diff --git a/scripts/verify.py b/scripts/verify.py index 2340a594..40fb5ac8 100755 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -1,305 +1,401 @@ -#! /usr/bin/env python3 - -import os, sys, subprocess, time, re, json, requests +#!/usr/bin/env python3 +""" +Contract verification script for Sky Protocol spells on Etherscan. +This script verifies both the DssSpell and DssSpellAction contracts. +""" +import os +import sys +import subprocess +import time +import re +import json +import requests from datetime import datetime - -api_key = '' -try: - api_key = os.environ['ETHERSCAN_API_KEY'] -except KeyError: - print(''' You need an Etherscan Api Key to verify contracts. - Create one at https://etherscan.io/myapikey\n - Then export it with `export ETHERSCAN_API_KEY=xxxxxxxx' -''') - exit() - -document = '' -try: - document = open('out/dapp.sol.json') -except FileNotFoundError: - exit('run dapp build first') -try: - content = json.load(document) -except json.decoder.JSONDecodeError: - exit('run dapp build again') - -if len(sys.argv) not in [3, 4]: - print('''usage:\n -./verify.py
[constructorArgs] -''') - exit() - -contract_name = sys.argv[1] -contract_address = sys.argv[2] -print('Attempting to verify contract {0} at address {1} ...'.format( - contract_name, - contract_address -)) - -if len(contract_address) != 42: - exit('malformed address') -constructor_arguments = '' -if len(sys.argv) == 4: - constructor_arguments = sys.argv[3] - -contract_path = '' -for path in content['contracts'].keys(): - try: - content['contracts'][path][contract_name] - contract_path = path - except KeyError: - continue -if contract_path == '': - exit('contract name not found.') - -print('Obtaining chain... ') -cast_chain = subprocess.run(['cast', 'chain'], capture_output=True) -chain = cast_chain.stdout.decode('ascii').replace('\n', '') -print(chain) - -text_metadata = content['contracts'][contract_path][contract_name]['metadata'] -metadata = json.loads(text_metadata) -compiler_version = 'v' + metadata['compiler']['version'] -evm_version = metadata['settings']['evmVersion'] -optimizer_enabled = metadata['settings']['optimizer']['enabled'] -optimizer_runs = metadata['settings']['optimizer']['runs'] -license_name = metadata['sources'][contract_path]['license'] -license_numbers = { +from typing import Dict, Any, Tuple, Optional + +# Constants +ETHERSCAN_API_URL = 'https://api.etherscan.io/v2/api' +FLATTEN_OUTPUT_PATH = 'out/flat.sol' +SOURCE_FILE_PATH = 'src/DssSpell.sol' +LIBRARY_NAME = 'DssExecLib' +ETHERSCAN_SUBDOMAINS = { + '1': '' +} +LICENSE_NUMBERS = { 'GPL-3.0-or-later': 5, 'AGPL-3.0-or-later': 13 } -license_number = license_numbers[license_name] - -module = 'contract' -action = 'verifysourcecode' -code_format = 'solidity-single-file' - -flatten_output_path = 'out/flat.sol' -subprocess.run([ - 'forge', - 'flatten', - contract_path, - '--output', - flatten_output_path -]) -with open(flatten_output_path, 'r', encoding='utf-8') as code_file: - code = code_file.read() - -def get_block(signature, code, with_frame=False): - block_and_tail = code[code.find(signature) :] - start = float('inf') - level = 0 - for i, char in enumerate(block_and_tail): - if char == '{': - if i < start: - start = i + 1 - level += 1 - elif char == '}': - level -= 1 - if i >= start and level == 0: - if with_frame: - return block_and_tail[: i+1] - else: - return block_and_tail[start : i].strip() - raise ValueError('not found: ' + signature) - -def remove_line_comments(line): - no_inline = re.sub('//.*', '', line) - no_block_start = re.sub('/\*.*', '', no_inline) - no_block_end = re.sub('.*\*/', '', no_block_start) - return no_block_end - -def remove_comments(original_block): - original_lines = original_block.split('\n') - lines = [] - in_comment = False - for original_line in original_lines: - line = remove_line_comments(original_line) - if not in_comment and line.strip() != '': - lines.append(line) - if '/*' in original_line: - in_comment = True - if '*/' in original_line: - in_comment = False - block = '\n'.join(lines) - return block - -lines = code.split('\n') -in_comment = False -libraries = {} - -for original_line in lines: - line = remove_line_comments(original_line) - if not in_comment and 'library' in line: - signature = re.sub('{.*', '', line) - block = get_block(signature, code) - libraries[signature] = block - if '/*' in original_line: - in_comment = True - if '*/' in original_line: - in_comment = False - -def select(library_name, block, external_code): - lines = block.split('\n') - lines.reverse() - for line in lines: - if 'function' in line: - signature = re.sub('\(.*', '', line) - name = re.sub('function', '', signature).strip() - full_name = library_name + '.' + name - if (external_code.count(full_name) == 0 - and block.count(name) == block.count(signature)): - function_block = get_block(signature, block, with_frame=True) - block = block.replace(function_block + '\n', '') - return block - -def get_warning(library_name): - return '''/* WARNING - -The following library code acts as an interface to the actual {} -library, which can be found in its own deployed contract. Only trust the actual -library's implementation. - - */ - -'''.format(library_name) - -def get_stubs(block): - original_lines = block.split('\n') - lines = [] - level = 0 - for line in original_lines: - if level == 0: - difference = line.count('{') - line.count('}') - lines.append(line + '}' * difference) - level += line.count('{') - level -= line.count('}') - return '\n'.join(lines) - -for signature, block in libraries.items(): - external_code = remove_comments(code.replace(block, '')) - library_name = re.sub('library ', '', signature).strip() - no_comments = remove_comments(block) - selected_pre = no_comments - selected_post = select(library_name, selected_pre, external_code) - while len(selected_post) < len(selected_pre): - selected_pre = selected_post - selected_post = select(library_name, selected_pre, external_code) - stubs = get_stubs(selected_post) - new_block = get_warning(library_name) + stubs - code = code.replace(block, new_block) - -def get_library_info(): - try: - library_name = "DssExecLib" - library_address = open('./DssExecLib.address').read() - return library_name, library_address - except FileNotFoundError: - raise ValueError('No Makefile found') - -library_name = '' -library_address = '' - -try: - library_name, library_address = get_library_info() -except ValueError as e: - print(e) - print('Assuming this contract uses no libraries') - -data = { - 'apikey': api_key, - 'module': module, - 'action': action, - 'contractaddress': contract_address, - 'sourceCode': code, - 'codeFormat': code_format, - 'contractName': contract_name, - 'compilerversion': compiler_version, - 'optimizationUsed': '1' if optimizer_enabled else '0', - 'runs': optimizer_runs, - 'constructorArguements': constructor_arguments, - 'evmversion': evm_version, - 'licenseType': license_number, - 'libraryname1': library_name, - 'libraryaddress1': library_address, -} -if chain in ['mainnet', 'ethlive']: - chain_separator = False - chain_id = '' -else: - chain_separator = True - chain_id = chain -url = 'https://api{0}{1}.etherscan.io/api'.format( - '-' if chain_separator else '', - chain_id -) +def get_env_var(var_name: str, error_message: str) -> str: + """ + Get environment variable with error handling. + """ + try: + return os.environ[var_name] + except KeyError: + print(f" {error_message}", file=sys.stderr) + sys.exit(1) + + +def get_chain_id() -> str: + """ + Get the current chain ID. + """ + print('Obtaining chain ID... ') + result = subprocess.run(['cast', 'chain-id'], capture_output=True) + chain_id = result.stdout.decode('utf-8').strip() + print(f"CHAIN_ID: {chain_id}") + return chain_id + + +def get_library_address() -> str: + """ + Find the DssExecLib address from either DssExecLib.address file or foundry.toml. + Returns an empty string if no library address is found. + """ + library_address = '' + + # First try to read from foundry.toml libraries + if os.path.exists('foundry.toml'): + try: + with open('foundry.toml', 'r') as f: + config = f.read() + + result = re.search(r':DssExecLib:(0x[0-9a-fA-F]{40})', config) + if result: + library_address = result.group(1) + print( + f'Using library {LIBRARY_NAME} at address {library_address}') + return library_address + else: + print('No DssExecLib configured in foundry.toml', file=sys.stderr) + except Exception as e: + print(f'Error reading foundry.toml: {str(e)}', file=sys.stderr) + else: + print('No foundry.toml found', file=sys.stderr) + + # If it cannot be found, try DssExecLib.address + if os.path.exists('DssExecLib.address'): + try: + print(f'Trying to read DssExecLib.address...', file=sys.stderr) + with open('DssExecLib.address', 'r') as f: + library_address = f.read().strip() + print(f'Using library {LIBRARY_NAME} at address {library_address}') + return library_address + except Exception as e: + print( + f'Error reading DssExecLib.address: {str(e)}', file=sys.stderr) + + # If we get here, no library address was found + print('WARNING: Assuming this contract uses no libraries', file=sys.stderr) + return '' + + +def parse_command_line_args() -> Tuple[str, str, str]: + """ + Parse command line arguments. + """ + if len(sys.argv) not in [3, 4]: + print("""usage:\n +./verify.py
[constructorArgs] +""", file=sys.stderr) + sys.exit(1) -headers = { - 'User-Agent': '' -} + contract_name = sys.argv[1] + contract_address = sys.argv[2] -def send(): - print('Sending verification request...') - verify = requests.post(url, headers = headers, data = data) - verify_response = {} - try: - verify_response = json.loads(verify.text) - except json.decoder.JSONDecodeError: - print(verify.text) - exit('Error: Etherscan responded with invalid JSON.') - return verify_response + if len(contract_address) != 42: + sys.exit('Malformed address') -verify_response = send() + constructor_args = '' + if len(sys.argv) == 4: + constructor_args = sys.argv[3] -while 'locate' in verify_response['result'].lower(): - print(verify_response['result']) - print('Waiting for 15 seconds for the network to update...') - time.sleep(15) - verify_response = send() + return contract_name, contract_address, constructor_args -if verify_response['status'] != '1' or verify_response['message'] != 'OK': - print('Error: ' + verify_response['result']) - exit() -print('Sent verification request with guid ' + verify_response['result']) +def flatten_source_code() -> None: + """ + Flatten the source code using Forge. + """ + subprocess.run([ + 'forge', 'flatten', + SOURCE_FILE_PATH, + '--output', FLATTEN_OUTPUT_PATH + ], capture_output=True) -guid = verify_response['result'] -check_response = {} +def send_etherscan_api_request(params: Dict[str, str], data: Dict[str, Any]) -> Dict: + """ + Sends the verification request to the Etherscan API + """ + headers = {'User-Agent': 'Sky-Protocol-Spell-Verifier'} -while check_response == {} or 'pending' in check_response['result'].lower(): + print('Sending verification request...', file=sys.stderr) + response = requests.post( + ETHERSCAN_API_URL, headers=headers, params=params, data=data) + + try: + return json.loads(response.text) + except json.decoder.JSONDecodeError: + print(response.text, file=sys.stderr) + raise Exception('Error: Etherscan responded with invalid JSON.') - if check_response != {}: - print(check_response['result']) - print('Waiting for 15 seconds for Etherscan to process the request...') - time.sleep(15) - check = requests.post(url, headers = headers, data = { +def get_contract_metadata(output_path: str, input_path: str) -> Dict[str, Any]: + """ + Extract contract metadata from the compiled output. + """ + try: + with open(output_path, 'r') as f: + content = json.load(f) + + metadata = content['metadata'] + license_name = metadata['sources'][input_path]['license'] + + return { + 'compiler_version': 'v' + metadata['compiler']['version'], + 'evm_version': metadata['settings']['evmVersion'], + 'optimizer_enabled': metadata['settings']['optimizer']['enabled'], + 'optimizer_runs': metadata['settings']['optimizer']['runs'], + # Default to AGPL-3.0-or-later if unknown + 'license_number': LICENSE_NUMBERS.get(license_name, LICENSE_NUMBERS['AGPL-3.0-or-later']) + } + except FileNotFoundError: + raise Exception('Run `forge build` and try again') + except json.decoder.JSONDecodeError: + raise Exception(f'Malformed JSON in {output_path}. Run `forge build --force` and try again') + except KeyError as e: + raise Exception(f'Missing metadata field: {e}') + + +def read_flattened_code() -> str: + """ + Read the flattened source code. + """ + with open(FLATTEN_OUTPUT_PATH, 'r', encoding='utf-8') as f: + return f.read() + + +def prepare_verification_data( + contract_name: str, + contract_address: str, + input_path: str, + output_path: str, + chain_id: str, + api_key: str, + constructor_args: str, + library_address: str +) -> Tuple[Dict[str, str], Dict[str, Any], str]: + """ + Prepare data for contract verification. + """ + # Get contract metadata + metadata = get_contract_metadata(output_path, input_path) + + # Read flattened source code + code = read_flattened_code() + + # Prepare API request parameters + params = {'chainid': chain_id} + + data = { + 'apikey': api_key, + 'module': 'contract', + 'action': 'verifysourcecode', + 'contractaddress': contract_address, + 'sourceCode': code, + 'codeFormat': 'solidity-single-file', + 'contractName': contract_name, + 'compilerversion': metadata['compiler_version'], + 'optimizationUsed': '1' if metadata['optimizer_enabled'] else '0', + 'runs': metadata['optimizer_runs'], + 'constructorArguements': constructor_args, + 'evmversion': metadata['evm_version'], + 'licenseType': metadata['license_number'], + 'libraryname1': LIBRARY_NAME, + 'libraryaddress1': library_address, + } + + return params, data, code + + +def wait_for_verification(guid: str, params: Dict[str, str], api_key: str, code: str) -> None: + """ + Wait for verification to complete and check status. + """ + check_data = { 'apikey': api_key, - 'module': module, + 'module': 'contract', 'action': 'checkverifystatus', 'guid': guid, - }) + } + + check_response = {} + + # Poll until verification is complete + while check_response == {} or 'pending' in check_response.get('result', '').lower(): + if check_response != {}: + print(check_response['result'], file=sys.stderr) + print( + 'Waiting for 15 seconds for Etherscan to process the request...', + file=sys.stderr + ) + time.sleep(15) + + check_response = send_etherscan_api_request( + params=params, data=check_data) + + # Check verification result + if check_response['status'] != '1' or check_response['message'] != 'OK': + if 'already verified' not in check_response['result'].lower(): + # Log the flattened source code for debugging + log_name = f'verify-{datetime.now().timestamp()}.log' + with open(log_name, 'w') as log: + log.write(code) + print(f'Source code logged to {log_name}', file=sys.stderr) + + raise Exception('Verification failed') + else: + print('Contract is already verified') + + +def verify_contract( + contract_name: str, + contract_address: str, + input_path: str, + output_path: str, + chain_id: str, + api_key: str, + constructor_args: str, + library_address: str +) -> None: + """ + Verify a contract on Etherscan. + """ + print(f'\nVerifying {contract_name} at {contract_address}...') + + # Prepare verification data + params, data, code = prepare_verification_data( + contract_name, contract_address, input_path, output_path, + chain_id, api_key, constructor_args, library_address + ) + + # Submit verification request + verify_response = send_etherscan_api_request(params, data) + + # Handle "contract not yet deployed" case + while 'locate' in verify_response.get('result', '').lower(): + print(verify_response['result'], file=sys.stderr) + print('Waiting for 15 seconds for the network to update...', file=sys.stderr) + time.sleep(15) + verify_response = send_etherscan_api_request(params, data) + + # Check verification submission status + if verify_response['status'] != '1' or verify_response['message'] != 'OK': + if 'already verified' in verify_response['result'].lower(): + print('Contract is already verified') + return + raise Exception('Failed to submit verification request') + + # Get verification GUID + guid = verify_response['result'] + print(f'Verification request submitted with GUID: {guid}') + + # Check verification status + wait_for_verification(guid, params, api_key, code) + # Get Etherscan URL + subdomain = ETHERSCAN_SUBDOMAINS.get(chain_id, '') + etherscan_url = f"https://{subdomain}etherscan.io/address/{contract_address}#code" + print(f'Contract verified successfully at {etherscan_url}') + + +def get_action_address(spell_address: str) -> Optional[str]: + """ + Get the action contract address from the spell contract. + """ try: - check_response = json.loads(check.text) - except json.decoder.JSONDecodeError: - print(check.text) - exit('Error: Etherscan responded with invalid JSON') - -if check_response['status'] != '1' or check_response['message'] != 'OK': - print('Error: ' + check_response['result']) - log_name = 'verify-{}.log'.format(datetime.now().timestamp()) - log = open(log_name, 'w') - log.write(code) - log.close() - print('log written to {}'.format(log_name)) - exit() - -print('Contract verified at https://{0}{1}etherscan.io/address/{2}#code'.format( - chain_id, - '.' if chain_separator else '', - contract_address -)) + result = subprocess.run( + ['cast', 'call', spell_address, 'action()(address)'], + capture_output=True, + env=os.environ | { + 'ETH_GAS_PRICE': '0', + 'ETH_PRIO_FEE': '0' + } + ) + return result.stdout.decode('utf-8').strip() + except Exception as e: + print(f'Error getting action address: {str(e)}', file=sys.stderr) + return None + + +def main(): + """ + Main entry point for the script. + """ + try: + # Get environment variables + api_key = get_env_var( + 'ETHERSCAN_API_KEY', + "You need an Etherscan API key to verify contracts.\n" + "Create one at https://etherscan.io/myapikey\n" + "Then export it with `export ETHERSCAN_API_KEY=xxxxxxxx'" + ) + + rpc_url = get_env_var( + 'ETH_RPC_URL', + "You need a valid ETH_RPC_URL.\n" + "Get a public one at https://chainlist.org/ or provide your own\n" + "Then export it with `export ETH_RPC_URL=https://....'" + ) + + # Parse command line arguments + spell_name, spell_address, constructor_args = parse_command_line_args() + + # Get chain ID + chain_id = get_chain_id() + + # Get library address + library_address = get_library_address() + + # Flatten source code + flatten_source_code() + + # Verify spell contract + verify_contract( + contract_name=spell_name, + contract_address=spell_address, + input_path=SOURCE_FILE_PATH, + output_path=f'out/DssSpell.sol/DssSpell.json', + chain_id=chain_id, + api_key=api_key, + constructor_args=constructor_args, + library_address=library_address + ) + + # Get and verify action contract + action_address = get_action_address(spell_address) + if not action_address: + raise Exception('Could not determine action contract address') + + verify_contract( + contract_name="DssSpellAction", + contract_address=action_address, + input_path=SOURCE_FILE_PATH, + output_path=f'out/DssSpell.sol/DssSpellAction.json', + chain_id=chain_id, + api_key=api_key, + constructor_args=constructor_args, + library_address=library_address + ) + + print('\nVerification complete!') + except Exception as e: + print(f'\nError: {str(e)}', file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main()