Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions scripts/verification/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Contract Verification

Minimal verification wrapper that shells out to `forge verify-contract` per explorer, using Foundry's built-in retries and delays.

## Usage

```bash
# from repo root
export ETH_RPC_URL="https://..."
# optional for Etherscan
export ETHERSCAN_API_KEY="..."

# optional overrides (defaults: 5)
export VERIFY_RETRIES=5
export VERIFY_DELAY=5

python -m scripts.verification.verify DssSpell 0xYourSpellAddress [constructorArgs]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Constructor args are not supported anymore.

Suggested change
python -m scripts.verification.verify DssSpell 0xYourSpellAddress [constructorArgs]
python -m scripts.verification.verify DssSpell 0x{spell_address}

```

This verifies:
- The Spell contract you pass (e.g., `DssSpell`)
- The associated `DssSpellAction` via `action()` lookup

## Explorers
- Sourcify: used on mainnet; no API key needed
- Etherscan: used on mainnet when `ETHERSCAN_API_KEY` is set

## Notes
- Libraries: if `DssExecLib` is configured in `foundry.toml`, it is linked automatically via `--libraries`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CLI no longer accepts constructor args, as we're relying on foundry.toml. Please update the README do reflect that.

- Retries & delay: handled by `forge verify-contract` flags (`--retries`, `--delay`) per Foundry docs ([forge verify-contract](https://getfoundry.sh/forge/reference/verify-contract#forge-verify-contract)).

## Examples
```bash
# Mainnet spell, with Etherscan
ETHERSCAN_API_KEY=... python -m scripts.verification.verify DssSpell 0xabc...def

# Custom retries/delay
VERIFY_RETRIES=10 VERIFY_DELAY=8 python -m scripts.verification.verify DssSpell 0xabc...def
```
11 changes: 11 additions & 0 deletions scripts/verification/__init__.py
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Question - what role does this index file play?

  2. Please adjust Makefile to a correct file since currently Make cannot find ./scripts/verify.py

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/usr/bin/env python3
"""
Verification package for Sky Protocol spells.
"""

from .contract_data import get_action_address, get_chain_id

__all__ = [
"get_chain_id",
"get_action_address",
]
44 changes: 44 additions & 0 deletions scripts/verification/contract_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""
Contract data utilities for Sky Protocol spells verification.
This module handles contract metadata extraction, source code flattening,
and other contract-related data operations.
"""
import os
import re
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused

Suggested change
import re

import subprocess
import sys
from typing import Optional

# Constants
SOURCE_FILE_PATH = "src/DssSpell.sol"
LIBRARY_NAME = "DssExecLib"
Comment on lines +13 to +15
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both unused in this file:

Suggested change
# Constants
SOURCE_FILE_PATH = "src/DssSpell.sol"
LIBRARY_NAME = "DssExecLib"



def get_chain_id() -> str:
"""Get the current chain ID."""
print("Obtaining chain ID... ")
result = subprocess.run(
["cast", "chain-id"], capture_output=True, text=True, check=True
)
chain_id = result.stdout.strip()
print(f"CHAIN_ID: {chain_id}")
return chain_id

def get_action_address(spell_address: str) -> Optional[str]:
"""Get the action contract address from the spell contract."""
try:
result = subprocess.run(
["cast", "call", spell_address, "action()(address)"],
capture_output=True,
text=True,
check=True,
env=os.environ | {"ETH_GAS_PRICE": "0", "ETH_PRIO_FEE": "0"},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question - why do ETH_GAS_PRICE and ETH_PRIO_FEE need to be set?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was a bug in cast call that caused it to fail to execute if the sender didn't have enough gas to fund the call, even though it's supposed to be a read-only function.
Usually this command is executed in the same shell that executed the deployment, so those variables would most likely be set.
Not sure if they fixed it, but explicitly setting the gas price and the prio fee to zero was the workaround.

)
return result.stdout.strip()
except subprocess.CalledProcessError as e:
print(f"Error getting action address: {str(e)}", file=sys.stderr)
return None
except Exception as e:
print(f"Unexpected error getting action address: {str(e)}", file=sys.stderr)
return None
205 changes: 205 additions & 0 deletions scripts/verification/verify.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""
Enhanced contract verification script for Sky Protocol spells on mainnet.
This script verifies both the DssSpell and DssSpellAction contracts on multiple block explorers
using forge verify-contract --flatten with robust retry mechanisms and fallback options.
"""
import os
import sys
import subprocess
from typing import Tuple, List

from . import get_chain_id, get_action_address

# Constants
SOURCE_FILE_PATH = 'src/DssSpell.sol'


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 parse_command_line_args() -> Tuple[str, str]:
"""Parse command line arguments."""
if len(sys.argv) != 3:
print("""usage:
./verify.py <contractname> <address>
""", file=sys.stderr)
sys.exit(1)

contract_name = sys.argv[1]
contract_address = sys.argv[2]

if len(contract_address) != 42:
sys.exit('Malformed address')
Comment on lines +38 to +39
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this can check against a regular expression which is safer for address detection since it also checks hex-format

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this check is necessary.
If it's not a valid address, it will fail anyway.
This is more a guard against forgetting to set the address, or setting it so something different because of bad copy-pasta.


return contract_name, contract_address


def build_forge_cmd(
verifier: str,
address: str,
contract_name: str,
retries: int,
delay: int,
etherscan_api_key: str = "",
) -> List[str]:
cmd: List[str] = [
"forge",
"verify-contract",
address,
f"{SOURCE_FILE_PATH}:{contract_name}",
"--verifier",
verifier,
"--flatten",
"--watch",
"--retries",
str(retries),
"--delay",
str(delay),
]

if verifier == "etherscan" and etherscan_api_key:
cmd.extend(["--etherscan-api-key", etherscan_api_key])

return cmd


def verify_once_on(
verifier: str,
address: str,
contract_name: str,
retries: int,
delay: int,
etherscan_api_key: str = "",
) -> bool:
cmd = build_forge_cmd(
verifier=verifier,
address=address,
contract_name=contract_name,
retries=retries,
delay=delay,
etherscan_api_key=etherscan_api_key,
)

print(f"\nVerifying {contract_name} at {address} on {verifier}...")
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=True,
)
# forge prints useful info; surface stdout
if result.stdout:
print(result.stdout.strip())
print(f"βœ“ {verifier} verification OK")
return True
except subprocess.CalledProcessError as e:
combined = (e.stdout or "") + "\n" + (e.stderr or "")
if "already verified" in combined.lower():
print(f"βœ“ {verifier}: already verified")
return True
print(f"βœ— {verifier} verification failed\n{combined}", file=sys.stderr)
return False


def verify_contract_with_verifiers(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably it's best to inject both chain_id and etherscan_api_key as parameters than to fetch them locally

Suggested change
def verify_contract_with_verifiers(
def verify_contract_with_verifiers(
contract_name: str,
contract_address: str,
chain_id: str,
etherscan_api_key: str,
) -> bool:

contract_name: str,
contract_address: str,
) -> bool:
"""Verify contract by issuing forge commands per explorer."""
# Configure retries/delay via env or defaults
retries = int(os.environ.get("VERIFY_RETRIES", "5"))
delay = int(os.environ.get("VERIFY_DELAY", "5"))
Comment on lines +118 to +119
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function has one too many levels of abstraction going on.
Parsing environment variables and getting their default values shouldn't be part of its responsibilities.
You most likely want to inject those already parsed into the function as parameters and let the top level call deal with those.


chain_id = get_chain_id()
etherscan_api_key = os.environ.get("ETHERSCAN_API_KEY", "")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please check for the presence of a bug caused by the presence of a non-empty ETHERSCAN_API_KEY environment variable which causes Forge to always verify against etherscan even when other verifier has been specified.

This only happens when:

  • ETHERSCAN_API_KEY was set to a non-empty value;
  • --verifier sourcify.

This combination causes forge to never verify on Sourcify.

It is caused by this line which doesn't use sourcify when ETHERSCAN_API_KEY is there.


successes = 0

# Sourcify (works without API key); blockscout pulls from it
if chain_id == "1":
if verify_once_on(
verifier="sourcify",
address=contract_address,
contract_name=contract_name,
retries=retries,
delay=delay,
):
successes += 1
else:
print(f"Sourcify not configured for CHAIN_ID {chain_id}, skipping.")

# Etherscan (requires API key)
if chain_id == "1" and etherscan_api_key:
Comment on lines +139 to +140
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awareness question - would hardcoding 1 allow for verification against Tenderly Testnets?
https://docs.tenderly.co/virtual-testnets/smart-contract-frameworks/foundry#verify-existing-contracts

From my understanding, testnets created from Mainnet will report chain ID of 1, so verify.py should work with them

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, as long as we don't change the chain ID for Tenderly Virtual Testnets, it's fine.
I don't think we have a use case to change it regardless.

if verify_once_on(
verifier="etherscan",
address=contract_address,
contract_name=contract_name,
retries=retries,
delay=delay,
etherscan_api_key=etherscan_api_key,
):
successes += 1
elif chain_id == "1":
print("ETHERSCAN_API_KEY not set; skipping Etherscan.")

return successes > 0


def main():
"""Main entry point for the enhanced verification script."""
try:
# Get environment variables
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 = parse_command_line_args()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
spell_name, spell_address = parse_command_line_args()
spell_name, spell_address = parse_command_line_args()
chain_id = get_chain_id()
etherscan_api_key = os.environ.get("ETHERSCAN_API_KEY", "")


# Verify spell contract
spell_success = verify_contract_with_verifiers(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
spell_success = verify_contract_with_verifiers(
spell_success = verify_contract_with_verifiers(
contract_name=spell_name,
contract_address=spell_address,
chain_id=chain_id,
etherscan_api_key=etherscan_api_key,
)

contract_name=spell_name,
contract_address=spell_address,
)

if not spell_success:
print("Failed to verify spell contract", file=sys.stderr)
sys.exit(1)

# Get and verify action contract
action_address = get_action_address(spell_address)
if not action_address:
print('Could not determine action contract address', file=sys.stderr)
sys.exit(1)

action_success = verify_contract_with_verifiers(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
action_success = verify_contract_with_verifiers(
action_success = verify_contract_with_verifiers(
contract_name="DssSpellAction",
contract_address=action_address,
chain_id=chain_id,
etherscan_api_key=etherscan_api_key,
)

contract_name="DssSpellAction",
contract_address=action_address,
)

if not action_success:
print("Failed to verify action contract", file=sys.stderr)
sys.exit(1)

print('\nπŸŽ‰ All verifications complete!')

except Exception as e:
print(f'\nError: {str(e)}', file=sys.stderr)
sys.exit(1)


if __name__ == "__main__":
main()


Loading