Skip to content

Commit

Permalink
CI: enhance CI to check storage layout backwards compatibility (#112)
Browse files Browse the repository at this point in the history
* ci: add job to compare storage for deployed contracts

* ci: fix compare-storage-layout

* fix: ci

* fix: add etherscan api key as secret

* fix: cache layout

* fix: resume all jobs

* Update .github/workflows/compare_storage_layout.py

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update .github/workflows/compare_storage_layout.py

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* refactor: simpler interface

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
  • Loading branch information
adu-web3 and coderabbitai[bot] authored Oct 21, 2024
1 parent cfbf125 commit d21c7d6
Show file tree
Hide file tree
Showing 4 changed files with 178 additions and 52 deletions.
87 changes: 87 additions & 0 deletions .github/workflows/compare_deployed_storage_layout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env python

import json
import subprocess
import pandas as pd
import os
from compare_storage_layout import parse_output, compare_layouts, get_current_layout

def get_deployed_addresses():
with open('script/deployedContracts.json', 'r') as f:
data = json.load(f)
return {
'Bootstrap': data['clientChain'].get('bootstrapLogic'),
'ClientChainGateway': data['clientChain'].get('clientGatewayLogic'),
'Vault': data['clientChain'].get('vaultImplementation'),
'RewardVault': data['clientChain'].get('rewardVaultImplementation'),
'ExoCapsule': data['clientChain'].get('capsuleImplementation')
}

def get_storage_layout(contract_name, address, rpc_url, etherscan_api_key):
if not address:
print(f"Skipping {contract_name} as it's not deployed.")
return pd.DataFrame()

result = subprocess.run(['cast', 'storage', address, '--rpc-url', rpc_url, '--etherscan-api-key', etherscan_api_key], capture_output=True, text=True)
print(f"finish executing: cast storage {address} --rpc-url ...")

if result.returncode != 0:
raise Exception(f"Error getting current layout for {contract_name}: {result.stderr}")

return parse_output(contract_name, result.stdout.split('\n'))

def load_and_parse_layout(contract_name, path):
with open(path, 'r') as f:
lines = f.readlines()
return parse_output(contract_name, lines)

if __name__ == "__main__":
try:
api_key = os.getenv('ALCHEMY_API_KEY')
if not api_key:
raise ValueError("ALCHEMY_API_KEY environment variable is not set")
etherscan_api_key = os.getenv('ETHERSCAN_API_KEY')
if not etherscan_api_key:
raise ValueError("ETHERSCAN_API_KEY environment variable is not set")

# Construct the RPC URL for Sepolia
rpc_url = f"https://eth-sepolia.g.alchemy.com/v2/{api_key}"

addresses = get_deployed_addresses()
all_mismatches = {}

for contract_name, address in addresses.items():
print(f"Checking {contract_name}...")
deployed_layout = get_storage_layout(contract_name, address, rpc_url, etherscan_api_key)
if deployed_layout.empty:
print(f"No deployed layout found for {contract_name}.")
continue

current_layout = get_current_layout(contract_name)
if current_layout.empty:
raise ValueError(f"Error: No valid entries of current layout found for {contract_name}.")

mismatches = compare_layouts(deployed_layout, current_layout)
if mismatches:
all_mismatches[contract_name] = mismatches

# then we load the layout file of ExocoreGateway on target branch and compare it with the current layout
print("Checking ExocoreGateway...")
target_branch_layout = load_and_parse_layout('ExocoreGateway', 'ExocoreGateway_target.txt')
current_layout = get_current_layout('ExocoreGateway')
mismatches = compare_layouts(target_branch_layout, current_layout)
if mismatches:
all_mismatches['ExocoreGateway'] = mismatches

if all_mismatches:
print("Mismatches found for current contracts:")
for contract, mismatches in all_mismatches.items():
print(f"{contract}:")
for mismatch in mismatches:
print(f" {mismatch}")
exit(1)
else:
print("Storage layout is compatible with all deployed contracts.")
except Exception as e:
print(f"Error: {e}")
exit(1)
105 changes: 59 additions & 46 deletions .github/workflows/compare_storage_layout.py
Original file line number Diff line number Diff line change
@@ -1,68 +1,81 @@
#!/usr/bin/env python

import pandas as pd
import os

def parse_layout(file_path):
expected_headers = ['Unnamed: 0', 'Name', 'Type', 'Slot', 'Offset', 'Bytes', 'Contract', 'Unnamed: 7']

if not os.path.isfile(file_path):
raise FileNotFoundError(f"Error: File {file_path} does not exist.")

# Read the file using pandas, with '|' as the delimiter
df = pd.read_csv(file_path, delimiter='|', engine='python', header=0)

# Trim leading/trailing whitespace from all columns
df.columns = [col.strip() for col in df.columns]
df = df.apply(lambda x: x.strip() if isinstance(x, str) else x)

# Check headers
if not all([df.columns[i] == expected_headers[i] for i in range(len(expected_headers))]):
raise ValueError(f"Error: Headers in {file_path} do not match expected headers.")

# Drop the second row (assuming it's a separator row)
df = df.drop(df.index[1])

# Combine relevant columns into a single string for comparison
df['Combined'] = df[['Name', 'Type', 'Slot', 'Offset', 'Bytes']].apply(lambda row: '|'.join(row.values), axis=1)

return df['Combined'].tolist()

def compare_layouts(clientChainGateway_entries, bootstrap_entries):
import subprocess

def parse_output(contract_name, lines):
# Clean up the output and create a dataframe
data = []
separator_line = len(lines)
for i, line in enumerate(lines): # start from the line next to the separator
if i > separator_line and line.startswith('|'):
parts = [part.strip() for part in line.split('|')[1:-1]] # Remove empty first and last elements
data.append(parts[:6]) # Keep Name, Type, Slot, Offset, Bytes, Contract
elif line.startswith('|') and 'Name' in line:
separator_line = i + 1

if not data:
raise Exception(f"No valid storage layout data found for {contract_name}")

df = pd.DataFrame(data, columns=['Name', 'Type', 'Slot', 'Offset', 'Bytes', 'Contract'])

# Convert numeric columns
for col in ['Slot', 'Offset', 'Bytes']:
df[col] = pd.to_numeric(df[col])

return df

def get_current_layout(contract_name):
result = subprocess.run(['forge', 'inspect', f'src/core/{contract_name}.sol:{contract_name}', 'storage-layout', '--pretty'], capture_output=True, text=True)
print(f"finished executing forge inspect for {contract_name}")

if result.returncode != 0:
raise Exception(f"Error getting current layout for {contract_name}: {result.stderr}")

return parse_output(contract_name, result.stdout.split('\n'))

def compare_layouts(old_layout, new_layout):
mismatches = []
length = len(bootstrap_entries)

if length > len(clientChainGateway_entries):
mismatches.append("Error: Bootstrap entries are more than ClientChainGateway entries.")
return mismatches

for i in range(length):
if bootstrap_entries[i] != clientChainGateway_entries[i]:
mismatches.append(f"Mismatch at position {i + 1}: {bootstrap_entries[i]} != {clientChainGateway_entries[i]}")
# Ensure both dataframes have the same columns
columns = ['Name', 'Type', 'Slot', 'Offset', 'Bytes']
old_layout = old_layout[columns].copy()
new_layout = new_layout[columns].copy()

# Compare non-gap variables
for index, row in old_layout.iterrows():
if row['Name'] != '__gap':
current_row = new_layout.loc[new_layout['Name'] == row['Name']]
if current_row.empty:
mismatches.append(f"Variable {row['Name']} is missing in the current layout")
elif not current_row.iloc[0].equals(row):
mismatches.append(f"Variable {row['Name']} has changed")

if not mismatches:
print("No mismatches found")

return mismatches

if __name__ == "__main__":
try:
clientChainGateway_entries = parse_layout("ClientChainGateway.md")
bootstrap_entries = parse_layout("Bootstrap.md")
clientChainGateway_layout = get_current_layout("ClientChainGateway")
bootstrap_layout = get_current_layout("Bootstrap")

if not clientChainGateway_entries:
raise ValueError("Error: No valid entries found in ClientChainGateway.md.")
if clientChainGateway_layout.empty:
raise ValueError("Error: No valid entries found for ClientChainGateway.")

if not bootstrap_entries:
raise ValueError("Error: No valid entries found in Bootstrap.md.")
if bootstrap_layout.empty:
raise ValueError("Error: No valid entries found for Bootstrap.")

mismatches = compare_layouts(clientChainGateway_entries, bootstrap_entries)
mismatches = compare_layouts(bootstrap_layout, clientChainGateway_layout)

if mismatches:
print(f"Mismatches found: {len(mismatches)}")
for mismatch in mismatches:
print(mismatch)
exit(1)
else:
print("All entries in Bootstrap are present in ClientChainGateway at the correct positions.")
print("All entries in Bootstrap match ClientChainGateway at the correct positions.")
except Exception as e:
print(e)
print(f"Error: {e}")
exit(1)

35 changes: 29 additions & 6 deletions .github/workflows/forge-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ jobs:
compare-storage-layout:
runs-on: ubuntu-latest
needs: build
env:
ALCHEMY_API_KEY: ${{ secrets.ALCHEMY_API_KEY }}
ETHERSCAN_API_KEY: ${{ secrets.ETHERSCAN_API_KEY }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
Expand All @@ -156,18 +159,38 @@ jobs:
./cache
./broadcast
key: ${{ runner.os }}-build-${{ github.sha }}
- name: Run forge inspect storage layout on ClientChainGateway
run: forge inspect --pretty src/core/ClientChainGateway.sol:ClientChainGateway storageLayout > ClientChainGateway.md
- name: Run forge inspect storage layout on Bootstrap
run: forge inspect --pretty src/core/Bootstrap.sol:Bootstrap storageLayout > Bootstrap.md
- name: Checkout target branch
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.ref }}
submodules: recursive
- name: Generate target branch layout files
run: |
forge inspect src/core/ExocoreGateway.sol:ExocoreGateway storage-layout --pretty > ExocoreGateway_target.txt
- name: Cache target branch layout file
uses: actions/cache/save@v3
with:
path: ExocoreGateway_target.txt
key: ${{ runner.os }}-exocore-target-${{ github.sha }}
- name: Checkout back to PR
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Restore target branch layout file
uses: actions/cache/restore@v3
with:
path: ExocoreGateway_target.txt
key: ${{ runner.os }}-exocore-target-${{ github.sha }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12.4'
- name: Install pandas
run: pip install --root-user-action=ignore pandas==2.2.2
- name: Run the comparison script
- name: Run the comparison script for Bootstrap and ClientChainGateway
run: python .github/workflows/compare_storage_layout.py
- name: Run the comparison script for deployed contracts
run: python .github/workflows/compare_deployed_storage_layout.py
- name: Add comment for storage layout mismatch failure
if: failure()
uses: actions/github-script@v6
Expand All @@ -178,5 +201,5 @@ jobs:
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'The storage layout of Bootstrap and ClientChainGateway is not the same. Please check the logs.'
body: 'Storage layout compatibility check failed. This could be due to a mismatch between Bootstrap and ClientChainGateway, or incompatibility with deployed contracts on Sepolia. Please check the logs for details.'
})
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@ node_modules
.idea

.gas-snapshot

## secret
.secrets

0 comments on commit d21c7d6

Please sign in to comment.