diff --git a/common/protob/messages-tezos.proto b/common/protob/messages-tezos.proto index e429ce8b86..d6fbfea65a 100644 --- a/common/protob/messages-tezos.proto +++ b/common/protob/messages-tezos.proto @@ -55,6 +55,8 @@ message TezosSignTx { optional TezosTransactionOp transaction = 4; // Tezos transaction operation optional TezosOriginationOp origination = 5; // Tezos origination operation optional TezosDelegationOp delegation = 6; // Tezos delegation operation + optional TezosProposalOp proposal = 7; // Tezos proposal operation + optional TezosBallotOp ballot = 8; // Tezos ballot operation /* * Tezos contract ID */ @@ -120,6 +122,29 @@ message TezosSignTx { optional uint64 storage_limit = 5; optional bytes delegate = 6; } + /** + * Structure representing information for proposal + */ + message TezosProposalOp { + optional bytes source = 1; //Contains only public_key_hash, not to be confused with TezosContractID + optional uint64 period = 2; + repeated bytes proposals = 4; + } + /** + * Structure representing information for ballot + */ + message TezosBallotOp { + optional bytes source = 1; //Contains only public_key_hash, not to be confused with TezosContractID + optional uint64 period = 2; + optional bytes proposal = 3; + optional TezosBallotType ballot = 4; + + enum TezosBallotType { + Yay = 0; + Nay = 1; + Pass = 2; + } + } } /** diff --git a/core/src/apps/common/writers.py b/core/src/apps/common/writers.py index a4ea21a580..feeb32590e 100644 --- a/core/src/apps/common/writers.py +++ b/core/src/apps/common/writers.py @@ -24,6 +24,13 @@ def write_uint16_le(w: bytearray, n: int) -> int: return 2 +def write_uint16_be(w: bytearray, n: int): + ensure(0 <= n <= 0xFFFF) + w.append((n >> 8) & 0xFF) + w.append(n & 0xFF) + return 2 + + def write_uint32_le(w: bytearray, n: int) -> int: ensure(0 <= n <= 0xFFFFFFFF) w.append(n & 0xFF) diff --git a/core/src/apps/tezos/helpers.py b/core/src/apps/tezos/helpers.py index 4bba52008d..73bd02d1f8 100644 --- a/core/src/apps/tezos/helpers.py +++ b/core/src/apps/tezos/helpers.py @@ -3,6 +3,7 @@ from micropython import const from trezor.crypto import base58 from apps.common import HARDENED +from apps.common.writers import write_uint8 TEZOS_AMOUNT_DIVISIBILITY = const(6) TEZOS_ED25519_ADDRESS_PREFIX = "tz1" @@ -21,6 +22,8 @@ TEZOS_PREFIX_BYTES = { "edsig": [9, 245, 205, 134, 18], # operation hash "o": [5, 116], + # protocol hash + "P": [2, 170], } @@ -42,13 +45,29 @@ def validate_full_path(path: list) -> bool: """ Validates derivation path to equal 44'/1729'/a', where `a` is an account index from 0 to 1 000 000. + Additional component added to allow ledger migration + 44'/1729'/0'/b' where `b` is an account index from 0 to 1 000 000 """ - if len(path) != 3: + length = len(path) + if length < 3 or length > 4: return False if path[0] != 44 | HARDENED: return False if path[1] != 1729 | HARDENED: return False - if path[2] < HARDENED or path[2] > 1000000 | HARDENED: - return False + if length == 3: + if path[2] < HARDENED or path[2] > 1000000 | HARDENED: + return False + if length == 4: + if path[2] != 0 | HARDENED: + return False + if path[3] < HARDENED or path[3] > 1000000 | HARDENED: + return False return True + + +def write_bool(w: bytearray, boolean: bool): + if boolean: + write_uint8(w, 255) + else: + write_uint8(w, 0) diff --git a/core/src/apps/tezos/layout.py b/core/src/apps/tezos/layout.py index 4b1528efe2..325d73b26d 100644 --- a/core/src/apps/tezos/layout.py +++ b/core/src/apps/tezos/layout.py @@ -1,5 +1,10 @@ -from trezor import ui -from trezor.messages import ButtonRequestType +from micropython import const + +from trezor import ui, wire +from trezor.messages import ButtonRequestType, MessageType +from trezor.messages.ButtonRequest import ButtonRequest +from trezor.ui.confirm import CANCELLED, ConfirmDialog +from trezor.ui.scroll import Scrollpage, animate_swipe, paginate from trezor.ui.text import Text from trezor.utils import chunks, format_amount @@ -66,6 +71,45 @@ def split_address(address): return chunks(address, 18) +def split_proposal(proposal): + return chunks(proposal, 17) + + def format_tezos_amount(value): formatted_value = format_amount(value, TEZOS_AMOUNT_DIVISIBILITY) return formatted_value + " XTZ" + + +async def require_confirm_ballot(ctx, proposal, ballot): + text = Text("Submit ballot", ui.ICON_SEND, icon_color=ui.PURPLE) + text.bold("Ballot: {}".format(ballot)) + text.bold("Proposal:") + text.mono(*split_proposal(proposal)) + await require_confirm(ctx, text, ButtonRequestType.SignTx) + + +# use, when there are more then one proposals in one operation +async def require_confirm_proposals(ctx, proposals): + await ctx.call(ButtonRequest(code=ButtonRequestType.SignTx), MessageType.ButtonAck) + first_page = const(0) + pages = proposals + title = "Submit proposals" if len(proposals) > 1 else "Submit proposal" + + paginator = paginate(show_proposal_page, len(pages), first_page, pages, title) + return await ctx.wait(paginator) + + +@ui.layout +async def show_proposal_page(page: int, page_count: int, pages: list, title: str): + text = Text(title, ui.ICON_SEND, icon_color=ui.PURPLE) + text.bold("Proposal {}: ".format(page + 1)) + text.mono(*split_proposal(pages[page])) + content = Scrollpage(text, page, page_count) + + if page + 1 >= page_count: + confirm = await ConfirmDialog(content) + if confirm == CANCELLED: + raise wire.ActionCancelled("Cancelled") + else: + content.render() + await animate_swipe() diff --git a/core/src/apps/tezos/sign_tx.py b/core/src/apps/tezos/sign_tx.py index 10227e2bd2..04ef8d5470 100644 --- a/core/src/apps/tezos/sign_tx.py +++ b/core/src/apps/tezos/sign_tx.py @@ -1,13 +1,22 @@ +from micropython import const + from trezor import wire from trezor.crypto import hashlib from trezor.crypto.curve import ed25519 -from trezor.messages import TezosContractType +from trezor.messages import TezosBallotType, TezosContractType from trezor.messages.TezosSignedTx import TezosSignedTx from apps.common import paths -from apps.common.writers import write_bytes, write_uint8 +from apps.common.writers import ( + write_bytes, + write_uint8, + write_uint16_be, + write_uint32_be, +) from apps.tezos import CURVE, helpers, layout +PROPOSAL_LENGTH = const(32) + async def sign_tx(ctx, msg, keychain): await paths.validate_path( @@ -52,6 +61,15 @@ async def sign_tx(ctx, msg, keychain): ctx, source, msg.delegation.fee ) + elif msg.proposal is not None: + proposed_protocols = [_get_protocol_hash(p) for p in msg.proposal.proposals] + await layout.require_confirm_proposals(ctx, proposed_protocols) + + elif msg.ballot is not None: + proposed_protocol = _get_protocol_hash(msg.ballot.proposal) + submitted_ballot = _get_ballot(msg.ballot.ballot) + await layout.require_confirm_ballot(ctx, proposed_protocol, submitted_ballot) + else: raise wire.DataError("Invalid operation") @@ -101,11 +119,24 @@ def _get_address_from_contract(address): raise wire.DataError("Invalid contract type") +def _get_protocol_hash(proposal): + return helpers.base58_encode_check(proposal, prefix="P") + + +def _get_ballot(ballot): + if ballot == TezosBallotType.Yay: + return "yay" + elif ballot == TezosBallotType.Nay: + return "nay" + elif ballot == TezosBallotType.Pass: + return "pass" + + def _get_operation_bytes(w: bytearray, msg): write_bytes(w, msg.branch) # when the account sends first operation in lifetime, - # we need to reveal its publickey + # we need to reveal its public key if msg.reveal is not None: _encode_common(w, msg.reveal, "reveal") write_bytes(w, msg.reveal.public_key) @@ -121,14 +152,18 @@ def _get_operation_bytes(w: bytearray, msg): _encode_common(w, msg.origination, "origination") write_bytes(w, msg.origination.manager_pubkey) _encode_zarith(w, msg.origination.balance) - _encode_bool(w, msg.origination.spendable) - _encode_bool(w, msg.origination.delegatable) + helpers.write_bool(w, msg.origination.spendable) + helpers.write_bool(w, msg.origination.delegatable) _encode_data_with_bool_prefix(w, msg.origination.delegate) _encode_data_with_bool_prefix(w, msg.origination.script) # delegation operation elif msg.delegation is not None: _encode_common(w, msg.delegation, "delegation") _encode_data_with_bool_prefix(w, msg.delegation.delegate) + elif msg.proposal is not None: + _encode_proposal(w, msg.proposal) + elif msg.ballot is not None: + _encode_ballot(w, msg.ballot) def _encode_common(w: bytearray, operation, str_operation): @@ -146,19 +181,12 @@ def _encode_contract_id(w: bytearray, contract_id): write_bytes(w, contract_id.hash) -def _encode_bool(w: bytearray, boolean): - if boolean: - write_uint8(w, 255) - else: - write_uint8(w, 0) - - def _encode_data_with_bool_prefix(w: bytearray, data): if data: - _encode_bool(w, True) + helpers.write_bool(w, True) write_bytes(w, data) else: - _encode_bool(w, False) + helpers.write_bool(w, False) def _encode_zarith(w: bytearray, num): @@ -171,3 +199,24 @@ def _encode_zarith(w: bytearray, num): break write_uint8(w, 128 | byte) + + +def _encode_proposal(w: bytearray, proposal): + proposal_tag = 5 + + write_uint8(w, proposal_tag) + write_bytes(w, proposal.source) + write_uint32_be(w, proposal.period) + write_uint32_be(w, len(proposal.proposals) * PROPOSAL_LENGTH) + for proposal_hash in proposal.proposals: + write_bytes(w, proposal_hash) + + +def _encode_ballot(w: bytearray, ballot): + ballot_tag = 6 + + write_uint8(w, ballot_tag) + write_bytes(w, ballot.source) + write_uint32_be(w, ballot.period) + write_bytes(w, ballot.proposal) + write_uint8(w, ballot.ballot) diff --git a/core/src/trezor/messages/TezosBallotOp.py b/core/src/trezor/messages/TezosBallotOp.py new file mode 100644 index 0000000000..cf6b969cd9 --- /dev/null +++ b/core/src/trezor/messages/TezosBallotOp.py @@ -0,0 +1,27 @@ +# Automatically generated by pb2py +# fmt: off +import protobuf as p + + +class TezosBallotOp(p.MessageType): + + def __init__( + self, + source: bytes = None, + period: int = None, + proposal: bytes = None, + ballot: int = None, + ) -> None: + self.source = source + self.period = period + self.proposal = proposal + self.ballot = ballot + + @classmethod + def get_fields(cls): + return { + 1: ('source', p.BytesType, 0), + 2: ('period', p.UVarintType, 0), + 3: ('proposal', p.BytesType, 0), + 4: ('ballot', p.UVarintType, 0), + } diff --git a/core/src/trezor/messages/TezosBallotType.py b/core/src/trezor/messages/TezosBallotType.py new file mode 100644 index 0000000000..1d955862f5 --- /dev/null +++ b/core/src/trezor/messages/TezosBallotType.py @@ -0,0 +1,5 @@ +# Automatically generated by pb2py +# fmt: off +Yay = 0 +Nay = 1 +Pass = 2 diff --git a/core/src/trezor/messages/TezosProposalOp.py b/core/src/trezor/messages/TezosProposalOp.py new file mode 100644 index 0000000000..d533ce6c94 --- /dev/null +++ b/core/src/trezor/messages/TezosProposalOp.py @@ -0,0 +1,30 @@ +# Automatically generated by pb2py +# fmt: off +import protobuf as p + +if __debug__: + try: + from typing import List + except ImportError: + List = None # type: ignore + + +class TezosProposalOp(p.MessageType): + + def __init__( + self, + source: bytes = None, + period: int = None, + proposals: List[bytes] = None, + ) -> None: + self.source = source + self.period = period + self.proposals = proposals if proposals is not None else [] + + @classmethod + def get_fields(cls): + return { + 1: ('source', p.BytesType, 0), + 2: ('period', p.UVarintType, 0), + 4: ('proposals', p.BytesType, p.FLAG_REPEATED), + } diff --git a/core/src/trezor/messages/TezosSignTx.py b/core/src/trezor/messages/TezosSignTx.py index 8127603780..cbba27e04f 100644 --- a/core/src/trezor/messages/TezosSignTx.py +++ b/core/src/trezor/messages/TezosSignTx.py @@ -2,8 +2,10 @@ # fmt: off import protobuf as p +from .TezosBallotOp import TezosBallotOp from .TezosDelegationOp import TezosDelegationOp from .TezosOriginationOp import TezosOriginationOp +from .TezosProposalOp import TezosProposalOp from .TezosRevealOp import TezosRevealOp from .TezosTransactionOp import TezosTransactionOp @@ -25,6 +27,8 @@ class TezosSignTx(p.MessageType): transaction: TezosTransactionOp = None, origination: TezosOriginationOp = None, delegation: TezosDelegationOp = None, + proposal: TezosProposalOp = None, + ballot: TezosBallotOp = None, ) -> None: self.address_n = address_n if address_n is not None else [] self.branch = branch @@ -32,6 +36,8 @@ class TezosSignTx(p.MessageType): self.transaction = transaction self.origination = origination self.delegation = delegation + self.proposal = proposal + self.ballot = ballot @classmethod def get_fields(cls): @@ -42,4 +48,6 @@ class TezosSignTx(p.MessageType): 4: ('transaction', TezosTransactionOp, 0), 5: ('origination', TezosOriginationOp, 0), 6: ('delegation', TezosDelegationOp, 0), + 7: ('proposal', TezosProposalOp, 0), + 8: ('ballot', TezosBallotOp, 0), } diff --git a/core/src/trezor/ui/scroll.py b/core/src/trezor/ui/scroll.py index 89d2fad39b..d144f76d7c 100644 --- a/core/src/trezor/ui/scroll.py +++ b/core/src/trezor/ui/scroll.py @@ -21,9 +21,9 @@ async def change_page(page, page_count): else: s = await swipe if s == SWIPE_UP: - return page + 1 # scroll down + return min(page + 1, page_count - 1) # scroll down elif s == SWIPE_DOWN: - return page - 1 # scroll up + return max(page - 1, 0) # scroll up async def paginate(render_page, page_count, page=0, *args): diff --git a/core/tests/test_apps.tezos.address.py b/core/tests/test_apps.tezos.address.py index 9b093f9e0b..c3870fd313 100644 --- a/core/tests/test_apps.tezos.address.py +++ b/core/tests/test_apps.tezos.address.py @@ -46,10 +46,11 @@ class TestTezosAddress(unittest.TestCase): [44 | HARDENED], [44 | HARDENED, 1729 | HARDENED], [44 | HARDENED, 1729 | HARDENED, 0], - [44 | HARDENED, 1729 | HARDENED, 0 | HARDENED, 0 | HARDENED], + [44 | HARDENED, 1729 | HARDENED, 0 | HARDENED, 0], [44 | HARDENED, 1729 | HARDENED, 0 | HARDENED, 0 | HARDENED, 0 | HARDENED], [44 | HARDENED, 1729 | HARDENED, 0 | HARDENED, 1, 0], [44 | HARDENED, 1729 | HARDENED, 0 | HARDENED, 0, 0], + [44 | HARDENED, 1729 | HARDENED, 1 | HARDENED, 1 | HARDENED], [44 | HARDENED, 1729 | HARDENED, 9999000 | HARDENED], [44 | HARDENED, 60 | HARDENED, 0 | HARDENED, 0, 0], [1 | HARDENED, 1 | HARDENED, 1 | HARDENED], @@ -58,6 +59,9 @@ class TestTezosAddress(unittest.TestCase): [44 | HARDENED, 1729 | HARDENED, 0 | HARDENED], [44 | HARDENED, 1729 | HARDENED, 3 | HARDENED], [44 | HARDENED, 1729 | HARDENED, 9 | HARDENED], + [44 | HARDENED, 1729 | HARDENED, 0 | HARDENED, 0 | HARDENED], + [44 | HARDENED, 1729 | HARDENED, 0 | HARDENED, 3 | HARDENED], + [44 | HARDENED, 1729 | HARDENED, 0 | HARDENED, 9 | HARDENED], ] for path in incorrect_paths: diff --git a/core/tests/test_apps.tezos.encode.py b/core/tests/test_apps.tezos.encode.py index ee875d3757..16f1142f4d 100644 --- a/core/tests/test_apps.tezos.encode.py +++ b/core/tests/test_apps.tezos.encode.py @@ -4,9 +4,8 @@ from common import * from trezor.messages import TezosContractType from trezor.messages.TezosContractID import TezosContractID -from apps.tezos.helpers import base58_decode_check, base58_encode_check +from apps.tezos.helpers import base58_decode_check, base58_encode_check, write_bool from apps.tezos.sign_tx import ( - _encode_bool, _encode_contract_id, _encode_data_with_bool_prefix, _encode_zarith, @@ -35,11 +34,11 @@ class TestTezosEncoding(unittest.TestCase): def test_tezos_encode_bool(self): w = bytearray() - _encode_bool(w, True) + write_bool(w, True) self.assertEqual(bytes(w), bytes([255])) w = bytearray() - _encode_bool(w, False) + write_bool(w, False) self.assertEqual(bytes(w), bytes([0])) def test_tezos_encode_contract_id(self): diff --git a/python/trezorlib/tests/device_tests/test_msg_tezos_sign_tx.py b/python/trezorlib/tests/device_tests/test_msg_tezos_sign_tx.py index d0478b5763..ecd6101a76 100644 --- a/python/trezorlib/tests/device_tests/test_msg_tezos_sign_tx.py +++ b/python/trezorlib/tests/device_tests/test_msg_tezos_sign_tx.py @@ -14,6 +14,8 @@ # You should have received a copy of the License along with this library. # If not, see . +import time + import pytest from trezorlib import messages, tezos @@ -23,6 +25,7 @@ from trezorlib.tools import parse_path from .common import TrezorTest TEZOS_PATH = parse_path("m/44'/1729'/0'") +TEZOS_PATH_10 = parse_path("m/44'/1729'/10'") @pytest.mark.tezos @@ -194,3 +197,152 @@ class TestMsgTezosSignTx(TrezorTest): assert ( resp.operation_hash == "oocgc3hyKsGHPsw6WFWJpWT8jBwQLtebQAXF27KNisThkzoj635" ) + + def input_flow(self, num_pages): + yield + time.sleep(1) + for _ in range(num_pages - 1): + self.client.debug.swipe_down() + time.sleep(1) + self.client.debug.press_yes() + + def test_tezos_sign_tx_proposal(self): + self.setup_mnemonic_allallall() + + self.client.set_input_flow(self.input_flow(num_pages=1)) + resp = tezos.sign_tx( + self.client, + TEZOS_PATH_10, + dict_to_proto( + messages.TezosSignTx, + { + "branch": "dee04042c0832d68a43699b2001c0a38065436eb05e578071a763e1972d0bc81", + "proposal": { + "source": "005f450441f41ee11eee78a31d1e1e55627c783bd6", + "period": 17, + "proposals": [ + "dfa974df171c2dad9a9b8f25d99af41fd9702ce5d04521d2f9943c84d88aa572" + ], + }, + }, + ), + ) + assert ( + resp.signature + == "edsigtfY16R32k2WVMYfFr7ymnro4ib5zMckk28vsuViYNN77DJAvCJLRNArd9L531pUCxT4YdcvCvBym5dhcZ1rknEVm6yZ8bB" + ) + assert ( + resp.sig_op_contents.hex() + == "dee04042c0832d68a43699b2001c0a38065436eb05e578071a763e1972d0bc8105005f450441f41ee11eee78a31d1e1e55627c783bd60000001100000020dfa974df171c2dad9a9b8f25d99af41fd9702ce5d04521d2f9943c84d88aa5723b12621296a679b3a74ea790df5347995a76e20a09e76590baaacf4e09341965a04123f5cbbba8427f045b5f7d59157a3098e44839babe7c247d19b58bbb2405" + ) + assert ( + resp.operation_hash == "opLqntFUu984M7LnGsFvfGW6kWe9QjAz4AfPDqQvwJ1wPM4Si4c" + ) + + def test_tezos_sign_tx_multiple_proposals(self): + self.setup_mnemonic_allallall() + + self.client.set_input_flow(self.input_flow(num_pages=2)) + resp = tezos.sign_tx( + self.client, + TEZOS_PATH_10, + dict_to_proto( + messages.TezosSignTx, + { + "branch": "7e0be36a90c663c73c60da3889ffefff1383fb65cc29f0639f173d8f95a52df7", + "proposal": { + "source": "005f450441f41ee11eee78a31d1e1e55627c783bd6", + "period": 17, + "proposals": [ + "2a6ff28ab4d0ccb18f7129aaaf9a4b8027d794f2562849665fdb6999db2a4e57", + "47cd60c09ab8437cc9fe19add494dce1b9844100f660f02ce77510a0c66d2762", + ], + }, + }, + ), + ) + assert ( + resp.signature + == "edsigu6GAjhiWAQ64ctWTGEDYAZ16tYzLgzWzqc4CUyixK4FGRE8YUBVzFaVJ2fUCexZjZLMLdiNZGcUdzeL1bQhZ2h5oLrh7pA" + ) + assert ( + resp.sig_op_contents.hex() + == "7e0be36a90c663c73c60da3889ffefff1383fb65cc29f0639f173d8f95a52df705005f450441f41ee11eee78a31d1e1e55627c783bd600000011000000402a6ff28ab4d0ccb18f7129aaaf9a4b8027d794f2562849665fdb6999db2a4e5747cd60c09ab8437cc9fe19add494dce1b9844100f660f02ce77510a0c66d2762f813361ac00ada7e3256f23973ae25b112229476a3cb3e506fe929ea1e9358299fed22178d1be689cddeedd1f303abfef859b664f159a528576a1c807079f005" + ) + assert ( + resp.operation_hash == "onobSyNgiitGXxSVFJN6949MhUomkkxvH4ZJ2owgWwNeDdntF9Y" + ) + + def test_tezos_sing_tx_ballot_yay(self): + self.setup_mnemonic_allallall() + + resp = tezos.sign_tx( + self.client, + TEZOS_PATH_10, + dict_to_proto( + messages.TezosSignTx, + { + "branch": "3a8f60c4cd394cee5b50136c7fc8cb157e8aaa476a9e5c68709be6fc1cdb5395", + "ballot": { + "source": "0002298c03ed7d454a101eb7022bc95f7e5f41ac78", + "period": 2, + "proposal": "def7ed9c84af23ab37ebb60dd83cd103d1272ad6c63d4c05931567e65ed027e3", + "ballot": 0, + }, + }, + ), + ) + + assert ( + resp.signature + == "edsigtkxNm6YXwtV24DqeuimeZFTeFCn2jDYheSsXT4rHMcEjNvzsiSo55nVyVsQxtEe8M7U4PWJWT4rGYYGckQCgtkNJkd2roX" + ) + + def test_tezos_sing_tx_ballot_nay(self): + self.setup_mnemonic_allallall() + + resp = tezos.sign_tx( + self.client, + TEZOS_PATH_10, + dict_to_proto( + messages.TezosSignTx, + { + "branch": "3a8f60c4cd394cee5b50136c7fc8cb157e8aaa476a9e5c68709be6fc1cdb5395", + "ballot": { + "source": "0002298c03ed7d454a101eb7022bc95f7e5f41ac78", + "period": 2, + "proposal": "def7ed9c84af23ab37ebb60dd83cd103d1272ad6c63d4c05931567e65ed027e3", + "ballot": 1, + }, + }, + ), + ) + assert ( + resp.signature + == "edsigtqLaizfF6Cfc2JQL7TrsyniGhpZEojZAKMFW6AeudaUoU8KGXEHJH69Q4Lf27qFyUSTfbeHNnnCt69SGEPWkmpkgkgqMbL" + ) + + def test_tezos_sing_tx_ballot_pass(self): + self.setup_mnemonic_allallall() + + resp = tezos.sign_tx( + self.client, + TEZOS_PATH_10, + dict_to_proto( + messages.TezosSignTx, + { + "branch": "3a8f60c4cd394cee5b50136c7fc8cb157e8aaa476a9e5c68709be6fc1cdb5395", + "ballot": { + "source": "0002298c03ed7d454a101eb7022bc95f7e5f41ac78", + "period": 2, + "proposal": "def7ed9c84af23ab37ebb60dd83cd103d1272ad6c63d4c05931567e65ed027e3", + "ballot": 2, + }, + }, + ), + ) + + assert ( + resp.signature + == "edsigu6YX7EegPwrpcEbdNQsNhrRiEagBNGJBmFamP4mixZZw1UynhahGQ8RNiZLSUVLERUZwygrsSVenBqXGt9VnknTxtzjKzv" + )