Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add proposal id option #63

Merged
merged 29 commits into from
Jan 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
cc93ba4
First step- seprate api logic from report crafting
nivcertora Jan 2, 2025
5b2f399
Move to data models + fixed tests
nivcertora Jan 2, 2025
59e6ddf
Extract all info for execution
nivcertora Jan 2, 2025
d82e269
Organize
nivcertora Jan 5, 2025
f54e723
Reverse mapping
nivcertora Jan 5, 2025
a57b84b
Add test for new entry points
nivcertora Jan 5, 2025
25d497f
Merge branch 'main' into niv/CERT-7885-Add-Proposal-id-option
nivcertora Jan 5, 2025
f6273dd
Merge branch 'main' of github.com:Certora/Quorum into niv/CERT-7885-A…
nivcertora Jan 5, 2025
55c53db
Merge branch 'niv/CERT-7885-Add-Proposal-id-option' of github.com:Cer…
nivcertora Jan 5, 2025
bea0486
Make README understandable
nivcertora Jan 5, 2025
51203f3
Address comments
nivcertora Jan 5, 2025
c96e420
Merge branch 'main' into niv/CERT-7885-Add-Proposal-id-option
nivcertora Jan 6, 2025
739e281
Intoduce command pattern
nivcertora Jan 6, 2025
cb40c8c
fix parse + CI
nivcertora Jan 6, 2025
9d8e472
Merge branch 'main' of github.com:Certora/Quorum into niv/CERT-7885-A…
nivcertora Jan 6, 2025
8288e48
.
nivcertora Jan 6, 2025
72c247e
Fix test
nivcertora Jan 6, 2025
8f09eca
Include Quroum package at install
nivcertora Jan 6, 2025
d57485b
Fix ci
nivcertora Jan 6, 2025
a51df2c
Try now
nivcertora Jan 6, 2025
7691e48
Address Review comments
nivcertora Jan 6, 2025
4097d3d
fix
nivcertora Jan 6, 2025
20a4b3c
Fix typing
nivcertora Jan 6, 2025
218e6df
Update README file
nivcertora Jan 6, 2025
b644502
.
nivcertora Jan 6, 2025
9415e04
.
nivcertora Jan 6, 2025
69891e6
Fix CI
nivcertora Jan 6, 2025
851b574
.
nivcertora Jan 6, 2025
551dc41
Improve description
nivcertora Jan 6, 2025
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
4 changes: 3 additions & 1 deletion .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,6 @@ jobs:
- name: Execute Regression Tests
run: |
pytest Quorum/tests --maxfail=1 --disable-warnings --tb=short
CheckProposal --config Quorum/tests/regression.json
Quorum single-payload --customer Aave --chain Ethereum --proposal_address 0xAD6c03BF78A3Ee799b86De5aCE32Bb116eD24637
Quorum config --config Quorum/tests/regression.json
Quorum proposal-id --proposal_id 137 --customer Aave
Empty file.
85 changes: 85 additions & 0 deletions Quorum/apis/governance/aave_governance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import requests

from Quorum.utils.chain_enum import Chain
from Quorum.apis.governance.data_models import BGDProposalData, PayloadAddresses

BASE_BGD_CACHE_REPO = 'https://raw.githubusercontent.com/bgd-labs/v3-governance-cache/refs/heads/main/cache'
PROPOSALS_URL = f'{BASE_BGD_CACHE_REPO}/1/0x9AEE0B04504CeF83A65AC3f0e838D0593BCb2BC7/proposals'

CHAIN_ID_TO_CHAIN = {
'1': Chain.ETH,
'42161': Chain.ARB,
'43114': Chain.AVAX,
'8453': Chain.BASE,
'56': Chain.BSC,
'100': Chain.GNO,
'10': Chain.OPT,
'137': Chain.POLY,
'534352': Chain.SCROLL,
'324': Chain.ZK,
'59144': Chain.LINEA,
}

class AaveGovernanceAPI:
"""
A utility class to interact with the BGD governance cache and retrieve
relevant information about Aave proposals and payload addresses.
"""

def __init__(self) -> None:
self.session = requests.Session()

def get_proposal_data(self, proposal_id: int) -> BGDProposalData:
"""
Fetches and returns the data for a given proposal.

Args:
proposal_id: The ID of the proposal to fetch.

Returns:
A BGDProposalData object.
"""
proposal_data_link = f'{PROPOSALS_URL}/{proposal_id}.json'
resp = self.session.get(proposal_data_link)
resp.raise_for_status()

raw_json = resp.json()
# Parse into our data model
return BGDProposalData(**raw_json)

def get_payload_addresses(self, chain_id: str, controller: str, payload_id: int) -> list[str]:
"""
Retrieves a list of payload addresses for a given payload ID, chain, and controller.

Args:
chain_id: The chain ID for the proposal.
controller: The controller for the proposal.
payload_id: The ID of the payload to fetch.

Returns:
A list of addresses that are part of the payload.
"""
url = f'{BASE_BGD_CACHE_REPO}/{chain_id}/{controller}/payloads/{payload_id}.json'
resp = self.session.get(url)
resp.raise_for_status()

payload_data = resp.json()
# We only need the 'target' field from each action
return [a['target'] for a in payload_data['payload']['actions']]

def get_all_payloads_addresses(self, proposal_id: int) -> list[PayloadAddresses]:
"""
Retrieves all payload addresses for a given proposal.

Args:
proposal_id: The ID of the proposal to fetch.

Returns:
A list of PayloadAddresses objects, each containing a chain ID and a list of addresses.
"""
data = self.get_proposal_data(proposal_id)
results = []
for p in data.proposal.payloads:
addresses = self.get_payload_addresses(p.chain, p.payloads_controller, p.payload_id)
results.append(PayloadAddresses(chain=CHAIN_ID_TO_CHAIN[p.chain], addresses=addresses))
return results
57 changes: 57 additions & 0 deletions Quorum/apis/governance/data_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from typing import List, Optional
from pydantic import BaseModel, Field

from Quorum.utils.chain_enum import Chain


class IPFSData(BaseModel):
title: str = 'N/A'
discussions: str = 'N/A'


class PayloadData(BaseModel):
chain: str
payloads_controller: str = Field(alias='payloadsController')
payload_id: int = Field(alias='payloadId')

class Config:
allow_population_by_alias = True


class ProposalData(BaseModel):
payloads: list[PayloadData] = Field(default_factory=list)
voting_portal: Optional[str] = Field(alias='votingPortal')
ipfs_hash: Optional[str] = Field(alias='ipfsHash')
access_level: Optional[str | int] = Field(alias='accessLevel')

class Config:
allow_population_by_alias = True


class EventArgs(BaseModel):
creator: str = 'N/A'
access_level: Optional[str | int] = Field(alias='accessLevel', default='N/A')
ipfs_hash: str = Field(alias='ipfsHash', default='N/A')

class Config:
allow_population_by_alias = True


class EventData(BaseModel):
transaction_hash: str = Field(alias='transactionHash')
args: EventArgs = Field(default_factory=EventArgs)


class BGDProposalData(BaseModel):
"""
Represents the entire JSON structure returned by the BGD cache
for a given proposal.
"""
ipfs: Optional[IPFSData] = None
proposal: Optional[ProposalData] = None
events: List[EventData] = Field(default_factory=list)


class PayloadAddresses(BaseModel):
chain: Chain
addresses: List[str]
146 changes: 82 additions & 64 deletions Quorum/auto_report/aave_tags.py
Original file line number Diff line number Diff line change
@@ -1,87 +1,105 @@
import requests
from dataclasses import dataclass
import json5 as json
from pydantic import BaseModel
from typing import Any, Dict

from Quorum.apis.governance.aave_governance import AaveGovernanceAPI
from Quorum.apis.governance.data_models import BGDProposalData, IPFSData, ProposalData, EventData


BASE_BGD_CACHE_REPO = 'https://raw.githubusercontent.com/bgd-labs/v3-governance-cache/refs/heads/main/cache'
PROPOSALS_URL = f'{BASE_BGD_CACHE_REPO}/1/0x9AEE0B04504CeF83A65AC3f0e838D0593BCb2BC7/proposals'
BASE_SEATBELT_REPO = 'https://github.com/bgd-labs/seatbelt-gov-v3/blob/main/reports'
SEATBELT_PAYLOADS_URL = f'{BASE_SEATBELT_REPO}/payloads'


@dataclass
class ChainInfo:
class ChainInfo(BaseModel):
name: str
block_explorer_link: str


AAVE_CHAIN_MAPPING = {
'1': ChainInfo('Ethereum', 'https://etherscan.io/address'),
'137': ChainInfo('Polygon', 'https://polygonscan.com/address'),
'43114': ChainInfo('Avalanche', 'https://snowtrace.io/address'),
'8453': ChainInfo('Base', 'https://basescan.org/address'),
'42161': ChainInfo('Arbitrum One', 'https://arbiscan.io/address'),
'1088': ChainInfo('Metis', 'https://explorer.metis.io/address'),
'10': ChainInfo('OP Mainnet', 'https://optimistic.etherscan.io/address'),
'56': ChainInfo('BNB Smart Chain', 'https://bscscan.com/address'),
'100': ChainInfo('Gnosis', 'https://gnosisscan.io/address'),
'534352': ChainInfo('Scroll', 'https://scrollscan.com/address'),
'324': ChainInfo('zkSync Era', 'https://era.zksync.network/address'),
"59144": ChainInfo('Linea', 'https://lineascan.build/'),
'1': ChainInfo(name='Ethereum', block_explorer_link='https://etherscan.io/address'),
'137': ChainInfo(name='Polygon', block_explorer_link='https://polygonscan.com/address'),
'43114': ChainInfo(name='Avalanche', block_explorer_link='https://snowtrace.io/address'),
'8453': ChainInfo(name='Base', block_explorer_link='https://basescan.org/address'),
'42161': ChainInfo(name='Arbitrum One', block_explorer_link='https://arbiscan.io/address'),
'1088': ChainInfo(name='Metis', block_explorer_link='https://explorer.metis.io/address'),
'10': ChainInfo(name='OP Mainnet', block_explorer_link='https://optimistic.etherscan.io/address'),
'56': ChainInfo(name='BNB Smart Chain',block_explorer_link='https://bscscan.com/address'),
'100': ChainInfo(name='Gnosis', block_explorer_link='https://gnosisscan.io/address'),
'534352':ChainInfo(name='Scroll', block_explorer_link='https://scrollscan.com/address'),
'324': ChainInfo(name='zkSync Era', block_explorer_link='https://era.zksync.network/address'),
'59144': ChainInfo(name='Linea', block_explorer_link='https://lineascan.build/')
}


def __extract_payload_addresses(session: requests.Session, chain_id: str, controller: str, payload_id: int) -> list[str]:
resp = session.get(f'{BASE_BGD_CACHE_REPO}/{chain_id}/{controller}/payloads/{payload_id}.json')
resp.raise_for_status()
def get_aave_tags(proposal_id: int) -> Dict[str, Any]:
"""
Utility function that orchestrates calls to AaveGovernanceAPI
and compiles the final dictionary of tags for a given proposal.

Returns:
A dictionary that can be directly rendered by your Jinja2 template.
"""
api = AaveGovernanceAPI()
bgd_data: BGDProposalData = api.get_proposal_data(proposal_id)

# Safely unwrap fields (some might be None).
ipfs_data: IPFSData = bgd_data.ipfs or IPFSData()
proposal_data: ProposalData = bgd_data.proposal or ProposalData()
create_event: EventData = bgd_data.events[0] if bgd_data.events else EventData()
Comment on lines +46 to +47
Copy link
Contributor

Choose a reason for hiding this comment

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

I never seen this syntax. Is it checking if the first expression is None and if so assigns the second expression?
Did you check this?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes, exactly. But must make sure bgd_data is not None.
Basically this replace the if/else one liner
(proposal_data: ProposalData = bgd_data.proposal if bgd_data.proposal is not None else ProposalData())
both expressions are equal

Copy link
Contributor

Choose a reason for hiding this comment

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

That's cool

Copy link
Collaborator

Choose a reason for hiding this comment

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

@nivcertora You haven't addressed this yet


# Construct an empty dictionary for the Jinja2 context
tags: Dict[str, Any] = {}

# Basic info
tags['proposal_id'] = str(proposal_id)
tags['proposal_title'] = ipfs_data.title
tags['voting_link'] = f'https://vote.onaave.com/proposal/?proposalId={proposal_id}'
tags['gov_forum_link'] = ipfs_data.discussions

# Multi-chain references
tags['chain'] = []
tags['payload_link'] = []
tags['payload_seatbelt_link'] = []

# Go through each payload in the proposal
for p in proposal_data.payloads:
# For each payload, retrieve the addresses from the API
addresses = api.get_payload_addresses(
chain_id = p.chain,
controller = p.payloads_controller,
payload_id = p.payload_id
)

# For each address, build up the chain/payload references
for i, address in enumerate(addresses, 1):
chain_info = AAVE_CHAIN_MAPPING.get(p.chain)
if not chain_info:
# If chain info is missing, skip
continue

payload_data = resp.json()
chain_display = chain_info.name + (f' {i}' if i != 1 else '')
tags['chain'].append(chain_display)

return [a['target'] for a in payload_data['payload']['actions']]
block_explorer_link = f'{chain_info.block_explorer_link}/{address}'
tags['payload_link'].append(block_explorer_link)

seatbelt_link = f'{SEATBELT_PAYLOADS_URL}/{p.chain}/{p.payloads_controller}/{p.payload_id}.md'
tags['payload_seatbelt_link'].append(seatbelt_link)

def get_aave_tags(proposal_id: int) -> dict:
with requests.Session() as session:
proposal_data_link = f'{PROPOSALS_URL}/{proposal_id}.json'
resp = session.get(proposal_data_link)
resp.raise_for_status()
# Transaction info
transaction_hash = create_event.transaction_hash
tags['transaction_hash'] = transaction_hash
tags['transaction_link'] = f'https://etherscan.io/tx/{transaction_hash}'

proposal_data: dict = resp.json()
# Creator + event args
args = create_event.args
tags['creator'] = args.creator
tags['access_level'] = args.access_level
tags['ipfs_hash'] = args.ipfs_hash

ipfs: dict = proposal_data.get('ipfs', {})
proposal: dict = proposal_data.get('proposal', {})
create_event: dict = proposal_data.get('events', [{}])[0] # The create event is always the first.
tags['createProposal_parameters_data'] = json.dumps(proposal_data.model_dump(), indent=4)

tags = {}
tags['proposal_id'] = str(proposal_id)
tags['proposal_title'] = ipfs.get('title', 'N/A')
tags['voting_link'] = f'https://vote.onaave.com/proposal/?proposalId={proposal_id}'
tags['gov_forum_link'] = ipfs.get('discussions', 'N/A')
# seatbelt link for entire proposal
tags['seatbelt_link'] = f'{BASE_SEATBELT_REPO}/proposals/{proposal_id}.md'

tags['chain'], tags['payload_link'], tags['payload_seatbelt_link'] = [], [], []
for p in proposal.get('payloads', []):
# These are necessary fields in the payload data to construct the payload fields.
if not all(k in p for k in ['chain', 'payloadsController', 'payloadId']):
continue
addresses = __extract_payload_addresses(session, p['chain'], p['payloadsController'], p['payloadId'])
for i, address in enumerate(addresses, 1):
tags['chain'].append(AAVE_CHAIN_MAPPING[p['chain']].name + (f' {i}' if i != 1 else ''))
tags['payload_link'].append(f'{AAVE_CHAIN_MAPPING[p["chain"]].block_explorer_link}/{address}')
tags['payload_seatbelt_link'].append(
f'{SEATBELT_PAYLOADS_URL}/{p["chain"]}/{p["payloadsController"]}/{p["payloadId"]}.md'
)

tags['transaction_hash'] = create_event.get('transactionHash', 'N/A')
tags['transaction_link'] = f'https://etherscan.io/tx/{tags["transaction_hash"]}'

args: dict = create_event.get('args', {})
tags['creator'] = args.get('creator', 'N/A')
tags['access_level'] = str(args.get('accessLevel', 'N/A'))
tags['ipfs_hash'] = args.get('ipfsHash', 'N/A')

tags['createProposal_parameters_data'] = json.dumps({k: proposal.get(k, 'N/A') for k
in ['payloads', 'votingPortal', 'ipfsHash']}, indent=4)

tags['seatbelt_link'] = f'{BASE_SEATBELT_REPO}/proposals/{proposal_id}.md'

return tags
return tags
Loading
Loading