From 9fc2f5e84d8d7fd4a15e277d5d422d95dc92a0c3 Mon Sep 17 00:00:00 2001 From: Petrovska Date: Wed, 7 Dec 2022 21:17:49 +0700 Subject: [PATCH 1/4] feat: update snapshot class for matching eip7712 specs and data structured expected --- great_ape_safe/ape_api/snapshot.py | 139 +++++++++++++++++++++-------- 1 file changed, 102 insertions(+), 37 deletions(-) diff --git a/great_ape_safe/ape_api/snapshot.py b/great_ape_safe/ape_api/snapshot.py index 9b4cf236..19d144f0 100644 --- a/great_ape_safe/ape_api/snapshot.py +++ b/great_ape_safe/ape_api/snapshot.py @@ -3,8 +3,9 @@ import time from rich.console import Console -from eth_account import messages -from brownie import interface +from eip712.hashing import hash_message as hash_eip712_message +from eip712.validation import validate_structured_data +from brownie import interface, web3 from helpers.addresses import registry @@ -18,8 +19,8 @@ def __init__(self, safe, proposal_id): self.sign_message_lib = interface.ISignMessageLib( registry.eth.gnosis.sign_message_lib, owner=self.safe.account ) - - self.vote_relayer = "https://relayer.snapshot.org/api/message" + # https://github.com/snapshot-labs/snapshot-relayer/blob/master/src/constants.json#L3 + self.vote_relayer = "https://relayer.snapshot.org/api/msg" self.subgraph = "https://hub.snapshot.org/graphql?" self.proposal_query = """ query($proposal_id: String) { @@ -39,6 +40,42 @@ def __init__(self, safe, proposal_id): "Content-Type": "application/json", "Referer": "https://snapshot.org/", } + self.domain = { + "name": "snapshot", + "version": "0.1.4", + } + # `string` proposal type: https://vote.convexfinance.com/#/proposal/bafkreihkhe75abvrfl67oz7ucxxm42mn3ofxb267z3wyzxj7rcc7e7hq3a + self.eip_712_type = { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + ], + "Vote": [ + {"name": "from", "type": "address"}, + {"name": "space", "type": "string"}, + {"name": "timestamp", "type": "uint64"}, + {"name": "proposal", "type": "string"}, + {"name": "choice", "type": "string"}, + {"name": "metadata", "type": "string"}, + ], + } + # `bytes32` proposal type: https://vote.aura.finance/#/proposal/0x022c66d408c9bccdf4f7e514718415d2717bc22290adea71f1b5261dbeb92f3c + self.eip_712_type_2 = { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + ], + "Vote": [ + {"name": "from", "type": "address"}, + {"name": "space", "type": "string"}, + {"name": "timestamp", "type": "uint64"}, + {"name": "proposal", "type": "bytes32"}, + {"name": "choice", "type": "string"}, + {"name": "reason", "type": "string"}, + {"name": "app", "type": "string"}, + {"name": "metadata", "type": "string"}, + ], + } self.proposal_id = proposal_id self.proposal_data = self._get_proposal_data(self.proposal_id) @@ -84,37 +121,45 @@ def create_payload_hash( timestamp=None, choice=None, proposal=None, - version="0.1.3", - type="vote", - metadata=None, + reason="", ): # helper method to create message hash from payload and output to console # can be used externally to verify generated hash if not payload: assert all([timestamp, choice]) - internal_payload = {} - internal_payload["proposal"] = ( - self.proposal_id if not proposal else proposal - ) - internal_payload["choice"] = self.format_choice(choice) - if metadata: - internal_payload["metadata"] = metadata + if self.proposal_id.startswith("0x"): + types = self.eip_712_type_2 + proposal = web3.toBytes(hexstr=self.proposal_id) + else: + types = self.eip_712_type + proposal = self.proposal_id payload = { - "version": version, - "timestamp": str(timestamp), - "space": self.proposal_data["space"]["id"], - "type": type, - "payload": internal_payload, + "domain": self.domain, + "message": { + "from": self.safe.address, + "space": self.proposal_data["space"]["id"], + "timestamp": int(timestamp), + "proposal": proposal, + "choice": json.dumps(self.format_choice(choice)), + "reason": reason, + "app": "snapshot", + "metadata": json.dumps({}), + }, + "primaryType": "Vote", + "types": types, } + + validate_structured_data(payload) - payload_stringify = json.dumps(payload, separators=(",", ":")) - hash = messages.defunct_hash_message(text=payload_stringify) + # https://github.com/ApeWorX/eip712/blob/main/eip712/hashing.py#L261 + hash = hash_eip712_message(payload) console.print(f"msg hash: {hash.hex()}") - return hash, payload_stringify - def vote_and_post(self, choice, version="0.1.3", type="vote", metadata=None): + return hash + + def vote_and_post(self, choice, reason="", metadata=None): # given a choice, contruct payload, post to vote relayer and post safe tx # for single vote, pass in choice as str ex: "yes" # for weighted vote, pass in choice(s) as dict ex: {"80/20 BADGER/WBTC": 1} @@ -128,22 +173,33 @@ def vote_and_post(self, choice, version="0.1.3", type="vote", metadata=None): choice_formatted = self.format_choice(choice) - internal_payload = {} - internal_payload["proposal"] = self.proposal_id - internal_payload["choice"] = choice_formatted - if metadata: - internal_payload["metadata"] = metadata + if self.proposal_id.startswith("0x"): + types = self.eip_712_type_2 + proposal = web3.toBytes(hexstr=self.proposal_id) + else: + types = self.eip_712_type + proposal = self.proposal_id payload = { - "version": version, - "timestamp": str(int(time.time())), - "space": space, - "type": type, - "payload": internal_payload, + "domain": self.domain, + "message": { + "from": self.safe.address, + "space": space, + "timestamp": int(time.time()), + "proposal": proposal, + "choice": json.dumps(choice_formatted, separators=(",", ":")), + "reason": reason, + "app": "snapshot", + "metadata": json.dumps({}) if not metadata else metadata, + }, + "primaryType": "Vote", + "types": types, } + validate_structured_data(payload) + console.print("payload", payload) - hash, payload_stringify = self.create_payload_hash(payload) + hash = self.create_payload_hash(payload) if is_weighted: for label, weight in choice_formatted.items(): @@ -155,20 +211,29 @@ def vote_and_post(self, choice, version="0.1.3", type="vote", metadata=None): tx_data = self.sign_message_lib.signMessage.encode_input(hash) - response = requests.post( + # NOTE: remove unused types as per endpoint 500 error + payload["types"].pop("EIP712Domain") + # NOTE: prior to json stringify, convert proposal[bytes -> string] + payload["message"]["proposal"] = self.proposal_id + + # https://github.com/snapshot-labs/snapshot-relayer/blob/master/src/api.ts#L63 + r = requests.post( self.vote_relayer, headers=self.headers, data=json.dumps( { "address": self.safe.address, - "msg": payload_stringify, + "data": payload, "sig": "0x", }, separators=(",", ":"), ), ) - self.handle_response(response) + # debugging their `msgHash` + print(r.json()["id"]) + + self.handle_response(r) safe_tx = self.safe.build_multisig_tx( to=registry.eth.gnosis.sign_message_lib, value=0, data=tx_data, operation=1 From 6da5d1e6267b47da80001eb52c8373bc34b9e4a1 Mon Sep 17 00:00:00 2001 From: Petrovska Date: Wed, 7 Dec 2022 21:29:49 +0700 Subject: [PATCH 2/4] feat: update type of string prop type to contain `reason` and `app` --- great_ape_safe/ape_api/snapshot.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/great_ape_safe/ape_api/snapshot.py b/great_ape_safe/ape_api/snapshot.py index 19d144f0..7b054b8b 100644 --- a/great_ape_safe/ape_api/snapshot.py +++ b/great_ape_safe/ape_api/snapshot.py @@ -56,6 +56,8 @@ def __init__(self, safe, proposal_id): {"name": "timestamp", "type": "uint64"}, {"name": "proposal", "type": "string"}, {"name": "choice", "type": "string"}, + {"name": "reason", "type": "string"}, + {"name": "app", "type": "string"}, {"name": "metadata", "type": "string"}, ], } @@ -150,7 +152,7 @@ def create_payload_hash( "primaryType": "Vote", "types": types, } - + validate_structured_data(payload) # https://github.com/ApeWorX/eip712/blob/main/eip712/hashing.py#L261 From 6e80c8dc773d8b780d38a2b7cfc79798088f8a4d Mon Sep 17 00:00:00 2001 From: Petrovska Date: Tue, 10 Jan 2023 21:38:09 +0700 Subject: [PATCH 3/4] feat: add test for facilitating debugging --- great_ape_safe/ape_api/snapshot.py | 45 ++++++++++++++------------ helpers/addresses.py | 33 ++++++++++--------- tests/conftest.py | 5 +++ tests/snapshot/conftest.py | 20 ++++++++++++ tests/snapshot/test_eip712_hash.py | 51 ++++++++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 36 deletions(-) create mode 100644 tests/snapshot/conftest.py create mode 100644 tests/snapshot/test_eip712_hash.py diff --git a/great_ape_safe/ape_api/snapshot.py b/great_ape_safe/ape_api/snapshot.py index 7b054b8b..69cd45df 100644 --- a/great_ape_safe/ape_api/snapshot.py +++ b/great_ape_safe/ape_api/snapshot.py @@ -82,6 +82,27 @@ def __init__(self, safe, proposal_id): self.proposal_id = proposal_id self.proposal_data = self._get_proposal_data(self.proposal_id) + def post_payload_relayer_api(self, payload): + # https://github.com/snapshot-labs/snapshot-relayer/blob/master/src/api.ts#L63 + r = requests.post( + self.vote_relayer, + headers=self.headers, + data=json.dumps( + { + "address": self.safe.address, + "data": payload, + "sig": "0x", + }, + separators=(",", ":"), + ), + ) + + self.handle_response(r) + + msg_hash = r.json()["id"] + + return msg_hash + def handle_response(self, response): if not response.ok: console.print(f"Error: {response.text}") @@ -157,9 +178,8 @@ def create_payload_hash( # https://github.com/ApeWorX/eip712/blob/main/eip712/hashing.py#L261 hash = hash_eip712_message(payload) - console.print(f"msg hash: {hash.hex()}") - return hash + return hash, payload def vote_and_post(self, choice, reason="", metadata=None): # given a choice, contruct payload, post to vote relayer and post safe tx @@ -201,7 +221,7 @@ def vote_and_post(self, choice, reason="", metadata=None): validate_structured_data(payload) console.print("payload", payload) - hash = self.create_payload_hash(payload) + hash, _ = self.create_payload_hash(payload) if is_weighted: for label, weight in choice_formatted.items(): @@ -218,24 +238,7 @@ def vote_and_post(self, choice, reason="", metadata=None): # NOTE: prior to json stringify, convert proposal[bytes -> string] payload["message"]["proposal"] = self.proposal_id - # https://github.com/snapshot-labs/snapshot-relayer/blob/master/src/api.ts#L63 - r = requests.post( - self.vote_relayer, - headers=self.headers, - data=json.dumps( - { - "address": self.safe.address, - "data": payload, - "sig": "0x", - }, - separators=(",", ":"), - ), - ) - - # debugging their `msgHash` - print(r.json()["id"]) - - self.handle_response(r) + self.post_payload_relayer_api(payload) safe_tx = self.safe.build_multisig_tx( to=registry.eth.gnosis.sign_message_lib, value=0, data=tx_data, operation=1 diff --git a/helpers/addresses.py b/helpers/addresses.py index 69e5c528..6e0d5e54 100644 --- a/helpers/addresses.py +++ b/helpers/addresses.py @@ -1050,22 +1050,25 @@ def checksum_address_dict(addresses): def get_registry(): - if chain.id == 1: + try: + if chain.id == 1: + return registry.eth + elif chain.id == 137: + return registry.poly + elif chain.id == 56: + return registry.bsc + elif chain.id == 42161: + return registry.arbitrum + elif chain.id == 250: + return registry.ftm + elif chain.id == 10: + return registry.op + elif chain.id == 42: + return registry.kovan + elif chain.id == 5: + return registry.goerli + except: return registry.eth - elif chain.id == 137: - return registry.poly - elif chain.id == 56: - return registry.bsc - elif chain.id == 42161: - return registry.arbitrum - elif chain.id == 250: - return registry.ftm - elif chain.id == 10: - return registry.op - elif chain.id == 42: - return registry.kovan - elif chain.id == 5: - return registry.goerli r = get_registry() diff --git a/tests/conftest.py b/tests/conftest.py index 8702ff35..88f0ca8d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,6 +35,11 @@ def ibbtc_msig(): return GreatApeSafe(registry.eth.badger_wallets.ibbtc_multisig) +@pytest.fixture +def voter_msig(): + return GreatApeSafe(registry.eth.badger_wallets.treasury_voter_multisig) + + @pytest.fixture def USDC(safe): Contract.from_explorer(registry.eth.treasury_tokens.USDC) diff --git a/tests/snapshot/conftest.py b/tests/snapshot/conftest.py new file mode 100644 index 00000000..acc58587 --- /dev/null +++ b/tests/snapshot/conftest.py @@ -0,0 +1,20 @@ +import pytest + +PROPOSAL_SINGLE_CHOICE = ( + "0xdded9266cc2c671793d6ee9db2b6328222399e7314a265b5ff50f3381edf48fc" +) +PROPOSAL_MULTI_CHOICE = ( + "0xc950294fd8c00b2901026b62b568b01923e62166fca1e96d738f4a27bdf26faf" +) + + +@pytest.fixture +def snapshot_single_choice(voter_msig): + voter_msig.init_snapshot(PROPOSAL_SINGLE_CHOICE) + return voter_msig.snapshot + + +@pytest.fixture +def snapshot_multi_choice(voter_msig): + voter_msig.init_snapshot(PROPOSAL_MULTI_CHOICE) + return voter_msig.snapshot diff --git a/tests/snapshot/test_eip712_hash.py b/tests/snapshot/test_eip712_hash.py new file mode 100644 index 00000000..89c4a9dc --- /dev/null +++ b/tests/snapshot/test_eip712_hash.py @@ -0,0 +1,51 @@ +import time + +""" +Reverting means a mismatch on the hash production and relayers may be looking for a different +hash from the safe events and the vote may not show up ever in the snapshot space as result +""" + + +def test_hash_single_choice(snapshot_single_choice): + ts = time.time() + singe_choice = "yes" + hash_class_generated, payload = snapshot_single_choice.create_payload_hash( + timestamp=ts, choice=singe_choice + ) + + # debugging eip712 hash produce from https://pypi.org/project/eip712/ package + print("Class generated hash:", hash_class_generated.hex()) + + # NOTE: remove unused types as per endpoint 500 error + payload["types"].pop("EIP712Domain") + # NOTE: prior to json stringify, convert proposal[bytes -> string] + payload["message"]["proposal"] = snapshot_single_choice.proposal_id + hash_api_generated = snapshot_single_choice.post_payload_relayer_api(payload) + + # debugging api `msgHash` + print("API generated hash:", hash_api_generated) + + assert hash_class_generated.hex() == hash_api_generated + + +def test_hash_multi_choice(snapshot_multi_choice): + ts = time.time() + multi_choice = {"80/20 BADGER/WBTC": 1, "40/40/20 WBTC/DIGG/graviAURA": 1} + + hash_class_generated, payload = snapshot_multi_choice.create_payload_hash( + timestamp=ts, choice=multi_choice + ) + + # debugging eip712 hash produce from https://pypi.org/project/eip712/ package + print("Class generated hash:", hash_class_generated.hex()) + + # NOTE: remove unused types as per endpoint 500 error + payload["types"].pop("EIP712Domain") + # NOTE: prior to json stringify, convert proposal[bytes -> string] + payload["message"]["proposal"] = snapshot_multi_choice.proposal_id + hash_api_generated = snapshot_multi_choice.post_payload_relayer_api(payload) + + # debugging api `msgHash` + print("API generated hash:", hash_api_generated) + + assert hash_class_generated.hex() == hash_api_generated From 2af240a089a731916ca657f88ffc57c6abafff49 Mon Sep 17 00:00:00 2001 From: Petrovska Date: Tue, 10 Jan 2023 21:44:09 +0700 Subject: [PATCH 4/4] feat: include urls for the proposal for addditional info guidance --- tests/snapshot/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/snapshot/conftest.py b/tests/snapshot/conftest.py index acc58587..031a1477 100644 --- a/tests/snapshot/conftest.py +++ b/tests/snapshot/conftest.py @@ -1,8 +1,10 @@ import pytest +# https://snapshot.org/#/cvx.eth/proposal/0xdded9266cc2c671793d6ee9db2b6328222399e7314a265b5ff50f3381edf48fc PROPOSAL_SINGLE_CHOICE = ( "0xdded9266cc2c671793d6ee9db2b6328222399e7314a265b5ff50f3381edf48fc" ) +# https://snapshot.org/#/aurafinance.eth/proposal/0xc950294fd8c00b2901026b62b568b01923e62166fca1e96d738f4a27bdf26faf PROPOSAL_MULTI_CHOICE = ( "0xc950294fd8c00b2901026b62b568b01923e62166fca1e96d738f4a27bdf26faf" )