From 03fed5a1c317ae5e5ef1c106b350f149dde9cdb6 Mon Sep 17 00:00:00 2001 From: JoeGruff Date: Sat, 30 Nov 2019 15:51:25 +0900 Subject: [PATCH 1/6] dcrdata: Add AgendaInfo When connecting to dcrdata, pull down the current agendas for use when voting. --- pydecred/dcrdata.py | 161 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 1 deletion(-) diff --git a/pydecred/dcrdata.py b/pydecred/dcrdata.py index 36f5bf94..f5989596 100644 --- a/pydecred/dcrdata.py +++ b/pydecred/dcrdata.py @@ -1,5 +1,6 @@ """ Copyright (c) 2019, Brian Stafford +Copyright (c) 2019, the Decred developers See LICENSE for details DcrdataClient.endpointList() for available enpoints. @@ -97,6 +98,7 @@ def getSocketURIs(uri): "/insight/api/addr/{address}/utxo", "/insight/api/addr/{address}/txs", "insight/api/tx/send" + "/stake/vote/info" ] class DcrdataClient(object): @@ -167,7 +169,7 @@ def endpointList(self): return [entry[1] for entry in self.listEntries] def endpointGuide(self): """ - Print on endpoint per line. + Print one endpoint per line. Each line shows a translation from Python notation to a URL. """ print("\n".join(["%s -> %s" % entry for entry in self.listEntries])) @@ -386,6 +388,149 @@ def __tojson__(self): tinyjson.register(TicketInfo, "TicketInfo") + +class AgendaChoices: + """ + Agenda choices such as abstain, yes, no. + """ + def __init__(self, ID, description, bits, isabstain, + isno, count, progress): + self.id = ID + self.description = description + self.bits = bits + self.isabstain = isabstain + self.isno = isno + self.count = count + self.progress = progress + + @staticmethod + def parse(obj): + return AgendaChoices( + ID=obj["id"], + description=obj["description"], + bits=obj["bits"], + isabstain=obj["isabstain"], + isno=obj["isno"], + count=obj["count"], + progress=obj["progress"], + ) + + @staticmethod + def __fromjson__(obj): + return AgendaChoices.parse(obj) + + def __tojson__(self): + return { + "id": self.id, + "description": self.description, + "bits": self.bits, + "isabstain": self.isabstain, + "isno": self.isno, + "count": self.count, + "progress": self.progress, + } + + +tinyjson.register(AgendaChoices, "AgendaChoices") + + +class Agenda: + """ + An agenda with name, description, and AgendaChoices. + """ + def __init__(self, ID, description, mask, starttime, expiretime, + status, quorumprogress, choices): + self.id = ID + self.description = description + self.mask = mask + self.starttime = starttime + self.expiretime = expiretime + self.status = status + self.quorumprogress = quorumprogress + self.choices = choices + + @staticmethod + def parse(obj): + return Agenda( + ID=obj["id"], + description=obj["description"], + mask=obj["mask"], + starttime=obj["starttime"], + expiretime=obj["expiretime"], + status=obj["status"], + quorumprogress=obj["quorumprogress"], + choices=[AgendaChoices.parse(choice) for choice in obj["choices"]], + ) + + @staticmethod + def __fromjson__(obj): + return AgendaChoices.parse(obj) + + def __tojson__(self): + return { + "id": self.id, + "description": self.description, + "mask": self.mask, + "starttime": self.starttime, + "expiretime": self.expiretime, + "status": self.status, + "quorumprogress": self.quorumprogress, + "choices": [choice.__tojson__() for choice in self.choices] + } + + +tinyjson.register(Agenda, "Agenda") + + +class AgendasInfo: + """ + All current agenda information for the current network. agendas contains + a list of Agenda. + """ + def __init__(self, currentheight, startheight, endheight, HASH, + voteversion, quorum, totalvotes, agendas): + self.currentheight = currentheight + self.startheight = startheight + self.endheight = endheight + self.hash = HASH + self.voteversion = voteversion + self.quorum = quorum + self.totalvotes = totalvotes + self.agendas = agendas + + @staticmethod + def parse(obj): + return AgendasInfo( + currentheight=obj["currentheight"], + startheight=obj["startheight"], + endheight=obj["endheight"], + HASH=obj["hash"], + voteversion=obj["voteversion"], + quorum=obj["quorum"], + totalvotes=obj["totalvotes"], + agendas=[Agenda.parse(agenda) for agenda in obj["agendas"]], + ) + + @staticmethod + def __fromjson__(obj): + return AgendasInfo.parse(obj) + + def __tojson__(self): + return { + "currentheight": self.currentheight, + "startheight": self.startheight, + "endheight": self.endheight, + "hash": self.hash, + "voteversion": self.voteversion, + "quorum": self.quorum, + "totalvotes": self.totalvotes, + "agendas": [agenda.__tojson__() for agenda in self.agendas], + } + + +tinyjson.register(AgendasInfo, "AgendasInfo") + + class UTXO(object): """ The UTXO is part of the wallet API. BlockChains create and parse UTXO @@ -616,6 +761,7 @@ def __init__(self, dbPath, params, datapath, skipConnect=False): self.addressReceiver = None self.datapath = datapath self.dcrdata = None + self.agendasInfo = None self.txDB = self.db.getBucket("tx") self.heightMap = self.db.getBucket("height", datatypes=("INTEGER", "BLOB")) self.headerDB = self.db.getBucket("header") @@ -633,6 +779,9 @@ def connect(self): self.datapath, emitter=self.pubsubSignal, ) + # Fetch agendas the first chance we get. + if not self.agendasInfo: + self.agendasInfo = self.getAgendasInfo() self.updateTip() def close(self): """ @@ -665,6 +814,16 @@ def subscribeAddresses(self, addrs, receiver=None): elif self.addressReceiver == None: raise Exception("must set receiver to subscribe to addresses") self.dcrdata.subscribeAddresses(addrs) + + def getAgendasInfo(self): + """ + The agendas info that are used for voting. + + Returns: + AgendasInfo: the current agendas. + """ + return AgendasInfo.parse(self.dcrdata.stake.vote.info()) + def processNewUTXO(self, utxo): """ Processes an as-received blockchain utxo. From eb12a813178ba388622048b52113d8bb8fc469b8 Mon Sep 17 00:00:00 2001 From: JoeGruff Date: Sat, 30 Nov 2019 15:52:40 +0900 Subject: [PATCH 2/6] ui: Add ability to change vote choice Add a screen to show current agendas and a dropdown menu to choose how to vote. --- pydecred/account.py | 2 +- pydecred/vsp.py | 1 + ui/qutilities.py | 25 ++++-- ui/screens.py | 215 +++++++++++++++++++++++++++++++++++++++++++- ui/ui.py | 3 +- 5 files changed, 238 insertions(+), 8 deletions(-) diff --git a/pydecred/account.py b/pydecred/account.py index 0964771b..c731d94a 100644 --- a/pydecred/account.py +++ b/pydecred/account.py @@ -424,4 +424,4 @@ def sync(self, blockchain, signals): return True -tinyjson.register(DecredAccount, "DecredAccount") \ No newline at end of file +tinyjson.register(DecredAccount, "DecredAccount") diff --git a/pydecred/vsp.py b/pydecred/vsp.py index 15ecc76b..8a62051d 100644 --- a/pydecred/vsp.py +++ b/pydecred/vsp.py @@ -287,6 +287,7 @@ def setVoteBits(self, voteBits): data = { "VoteBits": voteBits } res = tinyhttp.post(self.apiPath("voting"), data, headers=self.headers(), urlEncode=True) if resultIsSuccess(res): + self.purchaseInfo.voteBits = voteBits return True raise Exception("unexpected response from 'voting': %s" % repr(res)) diff --git a/ui/qutilities.py b/ui/qutilities.py index cf8c85a3..5ecaac65 100644 --- a/ui/qutilities.py +++ b/ui/qutilities.py @@ -383,6 +383,21 @@ def makeLabel(s, fontSize, a=ALIGN_CENTER, **k): lbl.setAlignment(a) return lbl + +def makeDropdown(choices): + """ + Create a QComboBox populated with choices. + + Args: + list(str/obj): The choices to display. + Returns: + QComboBox: An initiated QComboBox. + """ + dd = QtWidgets.QComboBox() + dd.addItems(choices) + return dd + + def setProperties(lbl, color=None, fontSize=None, fontFamily=None, underline=False): """ A few common properties of QLabels. @@ -532,12 +547,12 @@ def addClickHandler(wgt, cb): font-size:20px; } QComboBox{ - font-size:18px; - background-color:white; + font-size: 18px; + background-color: white; border: 1px solid gray; - padding-left:10px; - padding-right:15px; - font-weight:bold; + padding-left: 10px; + padding-right: 15px; + font-weight: bold; } QComboBox::drop-down { border-width: 1px; diff --git a/ui/screens.py b/ui/screens.py index 0461f7b5..de7c0dcb 100644 --- a/ui/screens.py +++ b/ui/screens.py @@ -1021,6 +1021,7 @@ def __init__(self, app): self.layout.setSpacing(20) self.poolScreen = PoolScreen(app, self.poolAuthed) self.accountScreen = PoolAccountScreen(app, self.poolScreen) + self.agendasScreen = AgendasScreen(app, self.accountScreen) self.balance = None self.wgt.setContentsMargins(5, 5, 5, 5) self.wgt.setMinimumWidth(400) @@ -1049,6 +1050,13 @@ def __init__(self, app): wgt, _ = Q.makeSeries(Q.HORIZONTAL, lbl, self.ticketCount, lbl2, self.ticketValue, unit) self.layout.addWidget(wgt) + # A button to view agendas and choose how to vote. + btn = app.getButton(SMALL, "Voting") + btn.clicked.connect(self.stackAgendas) + agendasWgt, _ = Q.makeSeries(Q.HORIZONTAL, btn) + self.layout.addWidget(agendasWgt) + + # Affordability. A row that reads `You can afford X tickets` lbl = Q.makeLabel("You can afford ", 14) self.affordLbl = Q.makeLabel(" ", 17, fontFamily="Roboto-Bold") @@ -1096,6 +1104,11 @@ def stacked(self): def stackAccounts(self): self.app.appWindow.stack(self.accountScreen) + def stackAgendas(self): + if len(self.agendasScreen.agendas) == 0 or self.accountScreen.currentPoolVoteBits < 0: + self.app.appWindow.showError("pool not yet synced") + return + self.app.appWindow.stack(self.agendasScreen) def setStats(self): """ Get the current ticket stats and update the display. @@ -1403,6 +1416,200 @@ def poolClicked(self): """ self.poolIp.setText(self.poolUrl.text()) + +class AgendasScreen(Screen): + """ + A screen that lists current agendas and allows for vote configuration. + """ + def __init__(self, app, accountScreen): + """ + Args: + app (TinyDecred): The TinyDecred application instance. + """ + super().__init__(app) + self.isPoppable = True + self.canGoHome = True + + self.pages = [] + self.page = 0 + self.ignoreVoteIndexChange = False + + self.app.registerSignal(ui.POOL_SIGNAL, self.setVote) + + self.accountScreen = accountScreen + self.app.registerSignal(ui.BLOCKCHAIN_CONNECTED, self.setAgendas) + self.wgt.setMinimumWidth(400) + self.wgt.setMinimumHeight(225) + + lbl = Q.makeLabel("Agendas", 18) + self.layout.addWidget(lbl, 0, Q.ALIGN_LEFT) + + wgt, self.agendasLyt = Q.makeWidget(QtWidgets.QWidget, Q.VERTICAL) + self.agendasLyt.setSpacing(10) + self.agendasLyt.setContentsMargins(5, 5, 5, 5) + self.layout.addWidget(wgt) + + self.prevPg = app.getButton(TINY, "back") + self.prevPg.clicked.connect(self.pageBack) + self.nextPg = app.getButton(TINY, "next") + self.nextPg.clicked.connect(self.pageFwd) + self.pgNum = Q.makeLabel("", 15) + + self.layout.addStretch(1) + + self.pagination, _ = Q.makeSeries(Q.HORIZONTAL, + self.prevPg, + Q.STRETCH, + self.pgNum, + Q.STRETCH, + self.nextPg) + self.layout.addWidget(self.pagination) + + def stacked(self): + """ + stacked is called on screens when stacked by the TinyDialog. + """ + pass + + def pageBack(self): + """ + Go back one page. + """ + newPg = self.page + 1 + if newPg > len(self.pages) - 1: + newPg = 0 + self.page = newPg + self.setAgendaWidgets(self.pages[newPg]) + self.setPgNum() + + def pageFwd(self): + """ + Go the the next displayed page. + """ + newPg = self.page - 1 + if newPg < 0: + newPg = len(self.pages) - 1 + self.page = newPg + self.setAgendaWidgets(self.pages[newPg]) + self.setPgNum() + + def setPgNum(self): + """ + Set the displayed page number. + """ + self.pgNum.setText("%d/%d" % (self.page+1, len(self.pages))) + + def setAgendas(self): + """ + Set agendas from dcrdata. + """ + agendas = self.app.dcrdata.agendasInfo.agendas + if len(agendas) == 0: + self.app.appWindow.showError("unable to set agendas") + return + self.agendas = agendas + self.pages = [agendas[i*2:i*2+2] for i in range((len(agendas)+1)//2)] + self.page = 0 + self.setAgendaWidgets(self.pages[0]) + self.pagination.setVisible(len(self.pages) > 1) + + def setVote(self): + """ + Set the users current vote choice. + """ + if len(self.agendas) == 0: + self.app.appWindow.showError("unable to set vote: no agendas") + return + voteBits = self.accountScreen.currentPoolVoteBits + if voteBits == -1: + self.app.appWindow.showError("unable to set vote: no votebits") + return + # Don't trigger our func watching these indexes. + self.ignoreVoteIndexChange = True + for dropdown in self.dropdowns: + originalIdx = dropdown.currentIndex() + index = 0 + if voteBits != 1: + bits = voteBits & dropdown.bitMask + for idx in range(len(dropdown.voteBitsList)): + # Check if this flag is set. + if bits == dropdown.voteBitsList[idx]: + index = idx + break + else: + self.app.appWindow.showError("unable to set vote: vote " + + "bit match not found") + return + if originalIdx != index: + dropdown.setCurrentIndex(index) + self.ignoreVoteIndexChange = False + + def setAgendaWidgets(self, agendas): + """ + Set the displayed agenda widgets. + """ + if len(agendas) == 0: + self.app.appWindow.showError("unable to set agendas") + return + Q.clearLayout(self.agendasLyt, delete=True) + # Store all current dropdowns here. + self.dropdowns = [] + for agenda in self.agendas: + nameLbl = Q.makeLabel(agenda.id, 16) + statusLbl = Q.makeLabel(agenda.status, 14) + choices = [choice.id for choice in agenda.choices] + nameWgt, _ = Q.makeSeries(Q.HORIZONTAL, nameLbl, + Q.STRETCH, statusLbl) + + # choicesDropdown is a dropdown menu that contains voting choices. + choicesDropdown = Q.makeDropdown(choices) + self.dropdowns.append(choicesDropdown) + # Vote bit indexes are the same as the dropdown's choice indexes. + voteBits = [choice.bits for choice in agenda.choices] + choicesDropdown.voteBitsList = voteBits + choicesDropdown.bitMask = agenda.mask + choicesDropdown.currentIndexChanged.connect(self.onChooseChoice) + + choicesWgt, _ = Q.makeSeries(Q.HORIZONTAL, choicesDropdown) + wgt, lyt = Q.makeSeries(Q.VERTICAL, nameWgt, choicesWgt) + wgt.setMinimumWidth(360) + lyt.setContentsMargins(5, 5, 5, 5) + Q.addDropShadow(wgt) + self.agendasLyt.addWidget(wgt, 1) + + def onChooseChoice(self, _): + """ + Called when a user has changed their vote. Loops through the current + choices and formats the current voteBits + """ + if self.ignoreVoteIndexChange: + return + acct = self.app.wallet.selectedAccount + if not acct: + log.error("no account selected") + self.app.appWindow.showError("unable to update votes: no account") + return + pools = acct.stakePools + if len(pools) == 0: + self.app.appWindow.showError("unable to update votes: no pools") + return + voteBits = self.accountScreen.currentPoolVoteBits + maxuint16 = (1 << 16) - 1 + for dropdown in self.dropdowns: + # Erase all choices. + voteBits &= maxuint16 ^ dropdown.bitMask + # Set the current choice. + voteBits |= dropdown.voteBitsList[dropdown.currentIndex()] + try: + pools[0].setVoteBits(voteBits) + self.accountScreen.currentPoolVoteBits = voteBits + self.app.appWindow.showSuccess("vote choices updated") + except Exception as e: + log.error("error changing vote: %s" % e) + self.app.appWindow.showError("unable to update vote choices: " + + "pool connection") + + class PoolAccountScreen(Screen): """ A screen that lists currently known VSP accounts, and allows adding new @@ -1420,6 +1627,8 @@ def __init__(self, app, poolScreen): self.pages = [] self.page = 0 + self.currentPoolVoteBits = -1 + self.poolScreen = poolScreen self.app.registerSignal(ui.SYNC_SIGNAL, self.setPools) self.wgt.setMinimumWidth(400) @@ -1494,6 +1703,10 @@ def setPools(self): pools = acct.stakePools if len(pools) == 0: return + # Refresh purchase info + pools[0].getPurchaseInfo() + self.currentPoolVoteBits = pools[0].purchaseInfo.voteBits + self.app.emitSignal(ui.POOL_SIGNAL) self.pages = [pools[i*2:i*2+2] for i in range((len(pools)+1)//2)] self.page = 0 self.setWidgets(self.pages[0]) @@ -1645,4 +1858,4 @@ def getTicketPrice(blockchain): return blockchain.stakeDiff()/1e8 except Exception as e: log.error("error fetching ticket price: %s" % e) - return False \ No newline at end of file + return False diff --git a/ui/ui.py b/ui/ui.py index 7bfeb97a..cf6b8ffa 100644 --- a/ui/ui.py +++ b/ui/ui.py @@ -17,4 +17,5 @@ WORKING_SIGNAL = "working_signal" DONE_SIGNAL = "done_signal" BLOCKCHAIN_CONNECTED = "blockchain_connected" -WALLET_CONNECTED = "wallet_connected" \ No newline at end of file +WALLET_CONNECTED = "wallet_connected" +POOL_SIGNAL = "pool_signal" From 796e2bf3d95962e3e5bafcd0882354c553ee9b40 Mon Sep 17 00:00:00 2001 From: JoeGruff Date: Wed, 4 Dec 2019 18:08:36 +0900 Subject: [PATCH 3/6] voting: Move get agendas info out of blockchain --- pydecred/dcrdata.py | 156 ------------------------------------------- pydecred/vsp.py | 159 ++++++++++++++++++++++++++++++++++++++++++++ ui/qutilities.py | 6 +- ui/screens.py | 119 ++++++++++++++++++++------------- ui/ui.py | 2 +- 5 files changed, 235 insertions(+), 207 deletions(-) diff --git a/pydecred/dcrdata.py b/pydecred/dcrdata.py index f5989596..b599a004 100644 --- a/pydecred/dcrdata.py +++ b/pydecred/dcrdata.py @@ -98,7 +98,6 @@ def getSocketURIs(uri): "/insight/api/addr/{address}/utxo", "/insight/api/addr/{address}/txs", "insight/api/tx/send" - "/stake/vote/info" ] class DcrdataClient(object): @@ -389,148 +388,6 @@ def __tojson__(self): tinyjson.register(TicketInfo, "TicketInfo") -class AgendaChoices: - """ - Agenda choices such as abstain, yes, no. - """ - def __init__(self, ID, description, bits, isabstain, - isno, count, progress): - self.id = ID - self.description = description - self.bits = bits - self.isabstain = isabstain - self.isno = isno - self.count = count - self.progress = progress - - @staticmethod - def parse(obj): - return AgendaChoices( - ID=obj["id"], - description=obj["description"], - bits=obj["bits"], - isabstain=obj["isabstain"], - isno=obj["isno"], - count=obj["count"], - progress=obj["progress"], - ) - - @staticmethod - def __fromjson__(obj): - return AgendaChoices.parse(obj) - - def __tojson__(self): - return { - "id": self.id, - "description": self.description, - "bits": self.bits, - "isabstain": self.isabstain, - "isno": self.isno, - "count": self.count, - "progress": self.progress, - } - - -tinyjson.register(AgendaChoices, "AgendaChoices") - - -class Agenda: - """ - An agenda with name, description, and AgendaChoices. - """ - def __init__(self, ID, description, mask, starttime, expiretime, - status, quorumprogress, choices): - self.id = ID - self.description = description - self.mask = mask - self.starttime = starttime - self.expiretime = expiretime - self.status = status - self.quorumprogress = quorumprogress - self.choices = choices - - @staticmethod - def parse(obj): - return Agenda( - ID=obj["id"], - description=obj["description"], - mask=obj["mask"], - starttime=obj["starttime"], - expiretime=obj["expiretime"], - status=obj["status"], - quorumprogress=obj["quorumprogress"], - choices=[AgendaChoices.parse(choice) for choice in obj["choices"]], - ) - - @staticmethod - def __fromjson__(obj): - return AgendaChoices.parse(obj) - - def __tojson__(self): - return { - "id": self.id, - "description": self.description, - "mask": self.mask, - "starttime": self.starttime, - "expiretime": self.expiretime, - "status": self.status, - "quorumprogress": self.quorumprogress, - "choices": [choice.__tojson__() for choice in self.choices] - } - - -tinyjson.register(Agenda, "Agenda") - - -class AgendasInfo: - """ - All current agenda information for the current network. agendas contains - a list of Agenda. - """ - def __init__(self, currentheight, startheight, endheight, HASH, - voteversion, quorum, totalvotes, agendas): - self.currentheight = currentheight - self.startheight = startheight - self.endheight = endheight - self.hash = HASH - self.voteversion = voteversion - self.quorum = quorum - self.totalvotes = totalvotes - self.agendas = agendas - - @staticmethod - def parse(obj): - return AgendasInfo( - currentheight=obj["currentheight"], - startheight=obj["startheight"], - endheight=obj["endheight"], - HASH=obj["hash"], - voteversion=obj["voteversion"], - quorum=obj["quorum"], - totalvotes=obj["totalvotes"], - agendas=[Agenda.parse(agenda) for agenda in obj["agendas"]], - ) - - @staticmethod - def __fromjson__(obj): - return AgendasInfo.parse(obj) - - def __tojson__(self): - return { - "currentheight": self.currentheight, - "startheight": self.startheight, - "endheight": self.endheight, - "hash": self.hash, - "voteversion": self.voteversion, - "quorum": self.quorum, - "totalvotes": self.totalvotes, - "agendas": [agenda.__tojson__() for agenda in self.agendas], - } - - -tinyjson.register(AgendasInfo, "AgendasInfo") - - class UTXO(object): """ The UTXO is part of the wallet API. BlockChains create and parse UTXO @@ -761,7 +618,6 @@ def __init__(self, dbPath, params, datapath, skipConnect=False): self.addressReceiver = None self.datapath = datapath self.dcrdata = None - self.agendasInfo = None self.txDB = self.db.getBucket("tx") self.heightMap = self.db.getBucket("height", datatypes=("INTEGER", "BLOB")) self.headerDB = self.db.getBucket("header") @@ -779,9 +635,6 @@ def connect(self): self.datapath, emitter=self.pubsubSignal, ) - # Fetch agendas the first chance we get. - if not self.agendasInfo: - self.agendasInfo = self.getAgendasInfo() self.updateTip() def close(self): """ @@ -815,15 +668,6 @@ def subscribeAddresses(self, addrs, receiver=None): raise Exception("must set receiver to subscribe to addresses") self.dcrdata.subscribeAddresses(addrs) - def getAgendasInfo(self): - """ - The agendas info that are used for voting. - - Returns: - AgendasInfo: the current agendas. - """ - return AgendasInfo.parse(self.dcrdata.stake.vote.info()) - def processNewUTXO(self, utxo): """ Processes an as-received blockchain utxo. diff --git a/pydecred/vsp.py b/pydecred/vsp.py index 8a62051d..b8732667 100644 --- a/pydecred/vsp.py +++ b/pydecred/vsp.py @@ -9,6 +9,149 @@ from tinydecred.crypto import crypto from tinydecred.crypto.bytearray import ByteArray + +class AgendaChoices: + """ + Agenda choices such as abstain, yes, no. + """ + def __init__(self, ID, description, bits, isabstain, + isno, count, progress): + self.id = ID + self.description = description + self.bits = bits + self.isabstain = isabstain + self.isno = isno + self.count = count + self.progress = progress + + @staticmethod + def parse(obj): + return AgendaChoices( + ID=obj["id"], + description=obj["description"], + bits=obj["bits"], + isabstain=obj["isabstain"], + isno=obj["isno"], + count=obj["count"], + progress=obj["progress"], + ) + + @staticmethod + def __fromjson__(obj): + return AgendaChoices.parse(obj) + + def __tojson__(self): + return { + "id": self.id, + "description": self.description, + "bits": self.bits, + "isabstain": self.isabstain, + "isno": self.isno, + "count": self.count, + "progress": self.progress, + } + + +tinyjson.register(AgendaChoices, "AgendaChoices") + + +class Agenda: + """ + An agenda with name, description, and AgendaChoices. + """ + def __init__(self, ID, description, mask, starttime, expiretime, + status, quorumprogress, choices): + self.id = ID + self.description = description + self.mask = mask + self.starttime = starttime + self.expiretime = expiretime + self.status = status + self.quorumprogress = quorumprogress + self.choices = choices + + @staticmethod + def parse(obj): + return Agenda( + ID=obj["id"], + description=obj["description"], + mask=obj["mask"], + starttime=obj["starttime"], + expiretime=obj["expiretime"], + status=obj["status"], + quorumprogress=obj["quorumprogress"], + choices=[AgendaChoices.parse(choice) for choice in obj["choices"]], + ) + + @staticmethod + def __fromjson__(obj): + return AgendaChoices.parse(obj) + + def __tojson__(self): + return { + "id": self.id, + "description": self.description, + "mask": self.mask, + "starttime": self.starttime, + "expiretime": self.expiretime, + "status": self.status, + "quorumprogress": self.quorumprogress, + "choices": [choice.__tojson__() for choice in self.choices] + } + + +tinyjson.register(Agenda, "Agenda") + + +class AgendasInfo: + """ + All current agenda information for the current network. agendas contains + a list of Agenda. + """ + def __init__(self, currentheight, startheight, endheight, HASH, + voteversion, quorum, totalvotes, agendas): + self.currentheight = currentheight + self.startheight = startheight + self.endheight = endheight + self.hash = HASH + self.voteversion = voteversion + self.quorum = quorum + self.totalvotes = totalvotes + self.agendas = agendas + + @staticmethod + def parse(obj): + return AgendasInfo( + currentheight=obj["currentheight"], + startheight=obj["startheight"], + endheight=obj["endheight"], + HASH=obj["hash"], + voteversion=obj["voteversion"], + quorum=obj["quorum"], + totalvotes=obj["totalvotes"], + agendas=[Agenda.parse(agenda) for agenda in obj["agendas"]], + ) + + @staticmethod + def __fromjson__(obj): + return AgendasInfo.parse(obj) + + def __tojson__(self): + return { + "currentheight": self.currentheight, + "startheight": self.startheight, + "endheight": self.endheight, + "hash": self.hash, + "voteversion": self.voteversion, + "quorum": self.quorum, + "totalvotes": self.totalvotes, + "agendas": [agenda.__tojson__() for agenda in self.agendas], + } + + +tinyjson.register(AgendasInfo, "AgendasInfo") + + def resultIsSuccess(res): """ JSON-decoded stake pool responses have a common base structure that enables @@ -172,6 +315,22 @@ def providers(net): vsps = tinyhttp.get("https://api.decred.org/?c=gsd") network = "testnet" if net.Name == "testnet3" else net.Name return [vsp for vsp in vsps.values() if vsp["Network"] == network] + + @staticmethod + def getAgendasInfo(net): + """ + The agendas info that are used for voting. + + Args: + dcrdata (object): a connection to dcrdata + + Returns: + AgendasInfo: the current agendas. + """ + network = "testnet" if net.Name == "testnet3" else net.Name + res = tinyhttp.get("https://%s.dcrdata.org/api/stake/vote/info" % network) + return AgendasInfo.parse(res) + def apiPath(self, command): """ The full URL for the specified command. diff --git a/ui/qutilities.py b/ui/qutilities.py index 5ecaac65..4c93aff0 100644 --- a/ui/qutilities.py +++ b/ui/qutilities.py @@ -547,7 +547,7 @@ def addClickHandler(wgt, cb): font-size:20px; } QComboBox{ - font-size: 18px; + font-size: 16px; background-color: white; border: 1px solid gray; padding-left: 10px; @@ -560,8 +560,8 @@ def addClickHandler(wgt, cb): border-left-style:solid; background-color:transparent; } -QComboBox::drop-down:hover { - background-color:#f1fff9; +QComboBox QAbstractItemView { + selection-color: #33aa33; } QComboBox::down-arrow { width:0; diff --git a/ui/screens.py b/ui/screens.py index de7c0dcb..e463dce9 100644 --- a/ui/screens.py +++ b/ui/screens.py @@ -1051,7 +1051,7 @@ def __init__(self, app): self.layout.addWidget(wgt) # A button to view agendas and choose how to vote. - btn = app.getButton(SMALL, "Voting") + btn = app.getButton(TINY, "Voting") btn.clicked.connect(self.stackAgendas) agendasWgt, _ = Q.makeSeries(Q.HORIZONTAL, btn) self.layout.addWidget(agendasWgt) @@ -1105,8 +1105,20 @@ def stacked(self): def stackAccounts(self): self.app.appWindow.stack(self.accountScreen) def stackAgendas(self): - if len(self.agendasScreen.agendas) == 0 or self.accountScreen.currentPoolVoteBits < 0: - self.app.appWindow.showError("pool not yet synced") + acct = self.app.wallet.selectedAccount + if not acct: + log.error("no account selected") + self.app.appWindow.showError("cannot vote: no account") + return + pools = acct.stakePools + if len(pools) == 0: + self.app.appWindow.showError("cannot vote: no pools") + return + if len(self.agendasScreen.agendas) == 0: + self.app.appWindow.showError("cannot vote: could not fetch agendas") + return + if not self.agendasScreen.voteSet: + self.app.appWindow.showError("cannot vote: pool not synced") return self.app.appWindow.stack(self.agendasScreen) def setStats(self): @@ -1391,6 +1403,8 @@ def registerPool(wallet): app.appWindow.showSuccess("pool authorized") wallet.openAccount.setPool(pool) wallet.save() + # Notify that vote data should be updated. + self.app.emitSignal(ui.PURCHASEINFO_SIGNAL) return True except Exception as e: err("pool authorization failed") @@ -1430,14 +1444,17 @@ def __init__(self, app, accountScreen): self.isPoppable = True self.canGoHome = True + # Currently shown agenda dropdowns are saved here. + self.dropdowns = [] self.pages = [] self.page = 0 self.ignoreVoteIndexChange = False + self.voteSet = False - self.app.registerSignal(ui.POOL_SIGNAL, self.setVote) + self.app.registerSignal(ui.PURCHASEINFO_SIGNAL, self.setVote) self.accountScreen = accountScreen - self.app.registerSignal(ui.BLOCKCHAIN_CONNECTED, self.setAgendas) + self.app.makeThread(self.getAgendaInfosFunc(), self.setAgendas) self.wgt.setMinimumWidth(400) self.wgt.setMinimumHeight(225) @@ -1449,20 +1466,20 @@ def __init__(self, app, accountScreen): self.agendasLyt.setContentsMargins(5, 5, 5, 5) self.layout.addWidget(wgt) - self.prevPg = app.getButton(TINY, "back") - self.prevPg.clicked.connect(self.pageBack) - self.nextPg = app.getButton(TINY, "next") - self.nextPg.clicked.connect(self.pageFwd) - self.pgNum = Q.makeLabel("", 15) + prevPg = app.getButton(TINY, "back") + prevPg.clicked.connect(self.pageBack) + nextPg = app.getButton(TINY, "next") + nextPg.clicked.connect(self.pageFwd) + pgNum = Q.makeLabel("", 15) self.layout.addStretch(1) self.pagination, _ = Q.makeSeries(Q.HORIZONTAL, - self.prevPg, + prevPg, Q.STRETCH, - self.pgNum, + pgNum, Q.STRETCH, - self.nextPg) + nextPg) self.layout.addWidget(self.pagination) def stacked(self): @@ -1471,6 +1488,13 @@ def stacked(self): """ pass + def getAgendaInfosFunc(self): + def func(): + try: + return VotingServiceProvider.getAgendasInfo(cfg.net) + except Exception as e: + log.error("error fetching vote info: %s" % e) + return func def pageBack(self): """ Go back one page. @@ -1499,16 +1523,12 @@ def setPgNum(self): """ self.pgNum.setText("%d/%d" % (self.page+1, len(self.pages))) - def setAgendas(self): + def setAgendas(self, agendasInfo): """ Set agendas from dcrdata. """ - agendas = self.app.dcrdata.agendasInfo.agendas - if len(agendas) == 0: - self.app.appWindow.showError("unable to set agendas") - return - self.agendas = agendas - self.pages = [agendas[i*2:i*2+2] for i in range((len(agendas)+1)//2)] + self.agendas = agendasInfo.agendas + self.pages = [self.agendas[i*2:i*2+2] for i in range((len(self.agendas)+1)//2)] self.page = 0 self.setAgendaWidgets(self.pages[0]) self.pagination.setVisible(len(self.pages) > 1) @@ -1517,13 +1537,20 @@ def setVote(self): """ Set the users current vote choice. """ + self.voteSet = False if len(self.agendas) == 0: self.app.appWindow.showError("unable to set vote: no agendas") return - voteBits = self.accountScreen.currentPoolVoteBits - if voteBits == -1: - self.app.appWindow.showError("unable to set vote: no votebits") + acct = self.app.wallet.selectedAccount + if not acct: + log.error("no account selected") + self.app.appWindow.showError("unable to update votes: no account") + return + pools = acct.stakePools + if len(pools) == 0: + self.app.appWindow.showError("unable to set vote: no pools") return + voteBits = pools[0].purchaseInfo.voteBits # Don't trigger our func watching these indexes. self.ignoreVoteIndexChange = True for dropdown in self.dropdowns: @@ -1543,6 +1570,7 @@ def setVote(self): if originalIdx != index: dropdown.setCurrentIndex(index) self.ignoreVoteIndexChange = False + self.voteSet = True def setAgendaWidgets(self, agendas): """ @@ -1552,11 +1580,11 @@ def setAgendaWidgets(self, agendas): self.app.appWindow.showError("unable to set agendas") return Q.clearLayout(self.agendasLyt, delete=True) - # Store all current dropdowns here. - self.dropdowns = [] - for agenda in self.agendas: + for agenda in agendas: nameLbl = Q.makeLabel(agenda.id, 16) statusLbl = Q.makeLabel(agenda.status, 14) + descriptionLbl = Q.makeLabel(agenda.description, 14) + descriptionLbl.setMargin(10) choices = [choice.id for choice in agenda.choices] nameWgt, _ = Q.makeSeries(Q.HORIZONTAL, nameLbl, Q.STRETCH, statusLbl) @@ -1571,7 +1599,7 @@ def setAgendaWidgets(self, agendas): choicesDropdown.currentIndexChanged.connect(self.onChooseChoice) choicesWgt, _ = Q.makeSeries(Q.HORIZONTAL, choicesDropdown) - wgt, lyt = Q.makeSeries(Q.VERTICAL, nameWgt, choicesWgt) + wgt, lyt = Q.makeSeries(Q.VERTICAL, nameWgt, descriptionLbl, choicesWgt) wgt.setMinimumWidth(360) lyt.setContentsMargins(5, 5, 5, 5) Q.addDropShadow(wgt) @@ -1585,29 +1613,28 @@ def onChooseChoice(self, _): if self.ignoreVoteIndexChange: return acct = self.app.wallet.selectedAccount - if not acct: - log.error("no account selected") - self.app.appWindow.showError("unable to update votes: no account") - return pools = acct.stakePools - if len(pools) == 0: - self.app.appWindow.showError("unable to update votes: no pools") - return - voteBits = self.accountScreen.currentPoolVoteBits + voteBits = pools[0].purchaseInfo.voteBits maxuint16 = (1 << 16) - 1 for dropdown in self.dropdowns: # Erase all choices. voteBits &= maxuint16 ^ dropdown.bitMask # Set the current choice. voteBits |= dropdown.voteBitsList[dropdown.currentIndex()] - try: - pools[0].setVoteBits(voteBits) - self.accountScreen.currentPoolVoteBits = voteBits - self.app.appWindow.showSuccess("vote choices updated") - except Exception as e: - log.error("error changing vote: %s" % e) - self.app.appWindow.showError("unable to update vote choices: " + - "pool connection") + + def changeVote(): + self.app.emitSignal(ui.WORKING_SIGNAL) + try: + pools[0].setVoteBits(voteBits) + self.app.appWindow.showSuccess("vote choices updated") + except Exception as e: + log.error("error changing vote: %s" % e) + self.app.appWindow.showError("unable to update vote choices: pool connection") + finally: + self.app.emitSignal(ui.DONE_SIGNAL) + + self.app.makeThread(changeVote) + class PoolAccountScreen(Screen): @@ -1627,8 +1654,6 @@ def __init__(self, app, poolScreen): self.pages = [] self.page = 0 - self.currentPoolVoteBits = -1 - self.poolScreen = poolScreen self.app.registerSignal(ui.SYNC_SIGNAL, self.setPools) self.wgt.setMinimumWidth(400) @@ -1705,8 +1730,8 @@ def setPools(self): return # Refresh purchase info pools[0].getPurchaseInfo() - self.currentPoolVoteBits = pools[0].purchaseInfo.voteBits - self.app.emitSignal(ui.POOL_SIGNAL) + # Notify that vote data should be updated. + self.app.emitSignal(ui.PURCHASEINFO_SIGNAL) self.pages = [pools[i*2:i*2+2] for i in range((len(pools)+1)//2)] self.page = 0 self.setWidgets(self.pages[0]) diff --git a/ui/ui.py b/ui/ui.py index cf6b8ffa..196d5ce3 100644 --- a/ui/ui.py +++ b/ui/ui.py @@ -18,4 +18,4 @@ DONE_SIGNAL = "done_signal" BLOCKCHAIN_CONNECTED = "blockchain_connected" WALLET_CONNECTED = "wallet_connected" -POOL_SIGNAL = "pool_signal" +PURCHASEINFO_SIGNAL = "purchaseinfo_signal" From 5473228c6625bec262a1ee0a2d43f1ac7e7ae1c2 Mon Sep 17 00:00:00 2001 From: JoeGruff Date: Thu, 5 Dec 2019 15:23:29 +0900 Subject: [PATCH 4/6] voting: Move agendas to DcrdataClient --- pydecred/dcrdata.py | 117 ++++++++++++++++++++++++++++++++ pydecred/vsp.py | 159 -------------------------------------------- ui/screens.py | 16 ++--- 3 files changed, 121 insertions(+), 171 deletions(-) diff --git a/pydecred/dcrdata.py b/pydecred/dcrdata.py index b599a004..9f7f5a2d 100644 --- a/pydecred/dcrdata.py +++ b/pydecred/dcrdata.py @@ -195,6 +195,16 @@ def subscribeAddresses(self, addrs): def subscribeBlocks(self): ps = self.psClient() ps.send(Sub.newblock) + + def getAgendasInfo(self): + """ + The agendas info that are used for voting. + + Returns: + AgendasInfo: the current agendas. + """ + return AgendasInfo.parse(self.stake.vote.info()) + @staticmethod def timeStringToUnix(fmtStr): return calendar.timegm(time.strptime(fmtStr, DcrdataClient.timeFmt)) @@ -388,6 +398,113 @@ def __tojson__(self): tinyjson.register(TicketInfo, "TicketInfo") +class AgendaChoices: + """ + Agenda choices such as abstain, yes, no. + """ + def __init__(self, ID, description, bits, isabstain, + isno, count, progress): + self.id = ID + self.description = description + self.bits = bits + self.isabstain = isabstain + self.isno = isno + self.count = count + self.progress = progress + + @staticmethod + def parse(obj): + return AgendaChoices( + ID=obj["id"], + description=obj["description"], + bits=obj["bits"], + isabstain=obj["isabstain"], + isno=obj["isno"], + count=obj["count"], + progress=obj["progress"], + ) + + @staticmethod + def __fromjson__(obj): + return AgendaChoices.parse(obj) + + +tinyjson.register(AgendaChoices, "AgendaChoices") + + +class Agenda: + """ + An agenda with name, description, and AgendaChoices. + """ + def __init__(self, ID, description, mask, starttime, expiretime, + status, quorumprogress, choices): + self.id = ID + self.description = description + self.mask = mask + self.starttime = starttime + self.expiretime = expiretime + self.status = status + self.quorumprogress = quorumprogress + self.choices = choices + + @staticmethod + def parse(obj): + return Agenda( + ID=obj["id"], + description=obj["description"], + mask=obj["mask"], + starttime=obj["starttime"], + expiretime=obj["expiretime"], + status=obj["status"], + quorumprogress=obj["quorumprogress"], + choices=[AgendaChoices.parse(choice) for choice in obj["choices"]], + ) + + @staticmethod + def __fromjson__(obj): + return AgendaChoices.parse(obj) + + +tinyjson.register(Agenda, "Agenda") + + +class AgendasInfo: + """ + All current agenda information for the current network. agendas contains + a list of Agenda. + """ + def __init__(self, currentheight, startheight, endheight, HASH, + voteversion, quorum, totalvotes, agendas): + self.currentheight = currentheight + self.startheight = startheight + self.endheight = endheight + self.hash = HASH + self.voteversion = voteversion + self.quorum = quorum + self.totalvotes = totalvotes + self.agendas = agendas + + @staticmethod + def parse(obj): + return AgendasInfo( + currentheight=obj["currentheight"], + startheight=obj["startheight"], + endheight=obj["endheight"], + HASH=obj["hash"], + voteversion=obj["voteversion"], + quorum=obj["quorum"], + totalvotes=obj["totalvotes"], + agendas=[Agenda.parse(agenda) for agenda in obj["agendas"]], + ) + + @staticmethod + def __fromjson__(obj): + return AgendasInfo.parse(obj) + + +tinyjson.register(AgendasInfo, "AgendasInfo") + + class UTXO(object): """ The UTXO is part of the wallet API. BlockChains create and parse UTXO diff --git a/pydecred/vsp.py b/pydecred/vsp.py index b8732667..8a62051d 100644 --- a/pydecred/vsp.py +++ b/pydecred/vsp.py @@ -9,149 +9,6 @@ from tinydecred.crypto import crypto from tinydecred.crypto.bytearray import ByteArray - -class AgendaChoices: - """ - Agenda choices such as abstain, yes, no. - """ - def __init__(self, ID, description, bits, isabstain, - isno, count, progress): - self.id = ID - self.description = description - self.bits = bits - self.isabstain = isabstain - self.isno = isno - self.count = count - self.progress = progress - - @staticmethod - def parse(obj): - return AgendaChoices( - ID=obj["id"], - description=obj["description"], - bits=obj["bits"], - isabstain=obj["isabstain"], - isno=obj["isno"], - count=obj["count"], - progress=obj["progress"], - ) - - @staticmethod - def __fromjson__(obj): - return AgendaChoices.parse(obj) - - def __tojson__(self): - return { - "id": self.id, - "description": self.description, - "bits": self.bits, - "isabstain": self.isabstain, - "isno": self.isno, - "count": self.count, - "progress": self.progress, - } - - -tinyjson.register(AgendaChoices, "AgendaChoices") - - -class Agenda: - """ - An agenda with name, description, and AgendaChoices. - """ - def __init__(self, ID, description, mask, starttime, expiretime, - status, quorumprogress, choices): - self.id = ID - self.description = description - self.mask = mask - self.starttime = starttime - self.expiretime = expiretime - self.status = status - self.quorumprogress = quorumprogress - self.choices = choices - - @staticmethod - def parse(obj): - return Agenda( - ID=obj["id"], - description=obj["description"], - mask=obj["mask"], - starttime=obj["starttime"], - expiretime=obj["expiretime"], - status=obj["status"], - quorumprogress=obj["quorumprogress"], - choices=[AgendaChoices.parse(choice) for choice in obj["choices"]], - ) - - @staticmethod - def __fromjson__(obj): - return AgendaChoices.parse(obj) - - def __tojson__(self): - return { - "id": self.id, - "description": self.description, - "mask": self.mask, - "starttime": self.starttime, - "expiretime": self.expiretime, - "status": self.status, - "quorumprogress": self.quorumprogress, - "choices": [choice.__tojson__() for choice in self.choices] - } - - -tinyjson.register(Agenda, "Agenda") - - -class AgendasInfo: - """ - All current agenda information for the current network. agendas contains - a list of Agenda. - """ - def __init__(self, currentheight, startheight, endheight, HASH, - voteversion, quorum, totalvotes, agendas): - self.currentheight = currentheight - self.startheight = startheight - self.endheight = endheight - self.hash = HASH - self.voteversion = voteversion - self.quorum = quorum - self.totalvotes = totalvotes - self.agendas = agendas - - @staticmethod - def parse(obj): - return AgendasInfo( - currentheight=obj["currentheight"], - startheight=obj["startheight"], - endheight=obj["endheight"], - HASH=obj["hash"], - voteversion=obj["voteversion"], - quorum=obj["quorum"], - totalvotes=obj["totalvotes"], - agendas=[Agenda.parse(agenda) for agenda in obj["agendas"]], - ) - - @staticmethod - def __fromjson__(obj): - return AgendasInfo.parse(obj) - - def __tojson__(self): - return { - "currentheight": self.currentheight, - "startheight": self.startheight, - "endheight": self.endheight, - "hash": self.hash, - "voteversion": self.voteversion, - "quorum": self.quorum, - "totalvotes": self.totalvotes, - "agendas": [agenda.__tojson__() for agenda in self.agendas], - } - - -tinyjson.register(AgendasInfo, "AgendasInfo") - - def resultIsSuccess(res): """ JSON-decoded stake pool responses have a common base structure that enables @@ -315,22 +172,6 @@ def providers(net): vsps = tinyhttp.get("https://api.decred.org/?c=gsd") network = "testnet" if net.Name == "testnet3" else net.Name return [vsp for vsp in vsps.values() if vsp["Network"] == network] - - @staticmethod - def getAgendasInfo(net): - """ - The agendas info that are used for voting. - - Args: - dcrdata (object): a connection to dcrdata - - Returns: - AgendasInfo: the current agendas. - """ - network = "testnet" if net.Name == "testnet3" else net.Name - res = tinyhttp.get("https://%s.dcrdata.org/api/stake/vote/info" % network) - return AgendasInfo.parse(res) - def apiPath(self, command): """ The full URL for the specified command. diff --git a/ui/screens.py b/ui/screens.py index e463dce9..aba93e9d 100644 --- a/ui/screens.py +++ b/ui/screens.py @@ -1452,9 +1452,9 @@ def __init__(self, app, accountScreen): self.voteSet = False self.app.registerSignal(ui.PURCHASEINFO_SIGNAL, self.setVote) + self.app.registerSignal(ui.BLOCKCHAIN_CONNECTED, self.setAgendas) self.accountScreen = accountScreen - self.app.makeThread(self.getAgendaInfosFunc(), self.setAgendas) self.wgt.setMinimumWidth(400) self.wgt.setMinimumHeight(225) @@ -1488,13 +1488,6 @@ def stacked(self): """ pass - def getAgendaInfosFunc(self): - def func(): - try: - return VotingServiceProvider.getAgendasInfo(cfg.net) - except Exception as e: - log.error("error fetching vote info: %s" % e) - return func def pageBack(self): """ Go back one page. @@ -1523,11 +1516,11 @@ def setPgNum(self): """ self.pgNum.setText("%d/%d" % (self.page+1, len(self.pages))) - def setAgendas(self, agendasInfo): + def setAgendas(self): """ Set agendas from dcrdata. """ - self.agendas = agendasInfo.agendas + self.agendas = self.app.dcrdata.dcrdata.getAgendasInfo().agendas self.pages = [self.agendas[i*2:i*2+2] for i in range((len(self.agendas)+1)//2)] self.page = 0 self.setAgendaWidgets(self.pages[0]) @@ -1630,8 +1623,7 @@ def changeVote(): except Exception as e: log.error("error changing vote: %s" % e) self.app.appWindow.showError("unable to update vote choices: pool connection") - finally: - self.app.emitSignal(ui.DONE_SIGNAL) + self.app.emitSignal(ui.DONE_SIGNAL) self.app.makeThread(changeVote) From 300854b26cf98c88d44b608334927ea2ac510b74 Mon Sep 17 00:00:00 2001 From: JoeGruff Date: Sat, 7 Dec 2019 20:07:51 +0900 Subject: [PATCH 5/6] ui: Optimize dropdown Save a reference to the drowdown in the function that fires when activated. Move agendasInfo back to dcrdata blockchain. --- pydecred/dcrdata.py | 41 +++++++-------------------- ui/screens.py | 69 +++++++++++++++++++++++++++------------------ 2 files changed, 52 insertions(+), 58 deletions(-) diff --git a/pydecred/dcrdata.py b/pydecred/dcrdata.py index 9f7f5a2d..ad5f3ac8 100644 --- a/pydecred/dcrdata.py +++ b/pydecred/dcrdata.py @@ -195,16 +195,6 @@ def subscribeAddresses(self, addrs): def subscribeBlocks(self): ps = self.psClient() ps.send(Sub.newblock) - - def getAgendasInfo(self): - """ - The agendas info that are used for voting. - - Returns: - AgendasInfo: the current agendas. - """ - return AgendasInfo.parse(self.stake.vote.info()) - @staticmethod def timeStringToUnix(fmtStr): return calendar.timegm(time.strptime(fmtStr, DcrdataClient.timeFmt)) @@ -424,13 +414,6 @@ def parse(obj): progress=obj["progress"], ) - @staticmethod - def __fromjson__(obj): - return AgendaChoices.parse(obj) - - -tinyjson.register(AgendaChoices, "AgendaChoices") - class Agenda: """ @@ -460,13 +443,6 @@ def parse(obj): choices=[AgendaChoices.parse(choice) for choice in obj["choices"]], ) - @staticmethod - def __fromjson__(obj): - return AgendaChoices.parse(obj) - - -tinyjson.register(Agenda, "Agenda") - class AgendasInfo: """ @@ -497,13 +473,6 @@ def parse(obj): agendas=[Agenda.parse(agenda) for agenda in obj["agendas"]], ) - @staticmethod - def __fromjson__(obj): - return AgendasInfo.parse(obj) - - -tinyjson.register(AgendasInfo, "AgendasInfo") - class UTXO(object): """ @@ -769,6 +738,16 @@ def subscribeBlocks(self, receiver): """ self.blockReceiver = receiver self.dcrdata.subscribeBlocks() + + def getAgendasInfo(self): + """ + The agendas info that is used for voting. + + Returns: + AgendasInfo: the current agendas. + """ + return AgendasInfo.parse(self.dcrdata.stake.vote.info()) + def subscribeAddresses(self, addrs, receiver=None): """ Subscribe to notifications for the provided addresses. diff --git a/ui/screens.py b/ui/screens.py index aba93e9d..127ebf7c 100644 --- a/ui/screens.py +++ b/ui/screens.py @@ -1450,9 +1450,10 @@ def __init__(self, app, accountScreen): self.page = 0 self.ignoreVoteIndexChange = False self.voteSet = False + self.blockchain = None self.app.registerSignal(ui.PURCHASEINFO_SIGNAL, self.setVote) - self.app.registerSignal(ui.BLOCKCHAIN_CONNECTED, self.setAgendas) + self.app.registerSignal(ui.BLOCKCHAIN_CONNECTED, self.setBlockchain) self.accountScreen = accountScreen self.wgt.setMinimumWidth(400) @@ -1516,11 +1517,18 @@ def setPgNum(self): """ self.pgNum.setText("%d/%d" % (self.page+1, len(self.pages))) + def setBlockchain(self): + """ + Set the dcrdata blockchain on connected signal. Then set agendas. + """ + self.blockchain = self.app.dcrdata + self.setAgendas() + def setAgendas(self): """ Set agendas from dcrdata. """ - self.agendas = self.app.dcrdata.dcrdata.getAgendasInfo().agendas + self.agendas = self.blockchain.getAgendasInfo().agendas self.pages = [self.agendas[i*2:i*2+2] for i in range((len(self.agendas)+1)//2)] self.page = 0 self.setAgendaWidgets(self.pages[0]) @@ -1544,8 +1552,6 @@ def setVote(self): self.app.appWindow.showError("unable to set vote: no pools") return voteBits = pools[0].purchaseInfo.voteBits - # Don't trigger our func watching these indexes. - self.ignoreVoteIndexChange = True for dropdown in self.dropdowns: originalIdx = dropdown.currentIndex() index = 0 @@ -1562,7 +1568,6 @@ def setVote(self): return if originalIdx != index: dropdown.setCurrentIndex(index) - self.ignoreVoteIndexChange = False self.voteSet = True def setAgendaWidgets(self, agendas): @@ -1589,7 +1594,8 @@ def setAgendaWidgets(self, agendas): voteBits = [choice.bits for choice in agenda.choices] choicesDropdown.voteBitsList = voteBits choicesDropdown.bitMask = agenda.mask - choicesDropdown.currentIndexChanged.connect(self.onChooseChoice) + choicesDropdown.lastIndex = 0 + choicesDropdown.activated.connect(self.onChooseChoiceFunc(choicesDropdown)) choicesWgt, _ = Q.makeSeries(Q.HORIZONTAL, choicesDropdown) wgt, lyt = Q.makeSeries(Q.VERTICAL, nameWgt, descriptionLbl, choicesWgt) @@ -1598,34 +1604,43 @@ def setAgendaWidgets(self, agendas): Q.addDropShadow(wgt) self.agendasLyt.addWidget(wgt, 1) - def onChooseChoice(self, _): - """ - Called when a user has changed their vote. Loops through the current - choices and formats the current voteBits + def onChooseChoiceFunc(self, dropdown): """ - if self.ignoreVoteIndexChange: - return - acct = self.app.wallet.selectedAccount - pools = acct.stakePools - voteBits = pools[0].purchaseInfo.voteBits - maxuint16 = (1 << 16) - 1 - for dropdown in self.dropdowns: + Called when a user has changed their vote. Changes the vote bits for + the dropdown's bit mask. + + Args: + dropdown (obj): the drowdown related to this function. + + Returns: + func: A function that is called upon the dropdown being activated. + """ + def func(idx): + if idx == dropdown.lastIndex: + return + acct = self.app.wallet.selectedAccount + pools = acct.stakePools + voteBits = pools[0].purchaseInfo.voteBits + maxuint16 = (1 << 16) - 1 # Erase all choices. voteBits &= maxuint16 ^ dropdown.bitMask # Set the current choice. voteBits |= dropdown.voteBitsList[dropdown.currentIndex()] - def changeVote(): - self.app.emitSignal(ui.WORKING_SIGNAL) - try: - pools[0].setVoteBits(voteBits) - self.app.appWindow.showSuccess("vote choices updated") - except Exception as e: - log.error("error changing vote: %s" % e) - self.app.appWindow.showError("unable to update vote choices: pool connection") - self.app.emitSignal(ui.DONE_SIGNAL) + def changeVote(): + self.app.emitSignal(ui.WORKING_SIGNAL) + try: + pools[0].setVoteBits(voteBits) + self.app.appWindow.showSuccess("vote choices updated") + dropdown.lastIndex = idx + except Exception as e: + log.error("error changing vote: %s" % e) + self.app.appWindow.showError("unable to update vote choices: pool connection") + dropdown.setCurrentIndex(dropdown.lastIndex) + self.app.emitSignal(ui.DONE_SIGNAL) - self.app.makeThread(changeVote) + self.app.makeThread(changeVote) + return func From a302fec52032751afa6f5b2b0bdb405327031d57 Mon Sep 17 00:00:00 2001 From: JoeGruff Date: Tue, 10 Dec 2019 14:34:59 +0900 Subject: [PATCH 6/6] multi: Add accountless ticket purchases If a vsp has APIVersionsSupported 3 allow accountless ticket purchasing for that pool. --- pydecred/account.py | 90 +++++++++++++++++++++++++++++++++------------ pydecred/vsp.py | 52 ++++++++++++++++++++++++-- ui/screens.py | 63 +++++++++++++++++++++++++------ 3 files changed, 167 insertions(+), 38 deletions(-) diff --git a/pydecred/account.py b/pydecred/account.py index c731d94a..e5cad398 100644 --- a/pydecred/account.py +++ b/pydecred/account.py @@ -6,6 +6,7 @@ support. """ +import time from tinydecred.wallet.accounts import Account from tinydecred.util import tinyjson, helpers from tinydecred.crypto.crypto import AddressSecpPubKey, CrazyKeyError @@ -115,6 +116,10 @@ def __fromjson__(obj): acct = Account.__fromjson__(obj, cls=DecredAccount) acct.tickets = obj["tickets"] acct.stakePools = obj["stakePools"] + # Temp fix for buck, as there will be no ID yet + for i in range(len(acct.stakePools)): + if acct.stakePools[i].ID < 0: + acct.stakePools[i].ID = i acct.updateStakeStats() return acct def open(self, pw): @@ -225,15 +230,31 @@ def votingAddress(self): AddressSecpPubkey: The address object. """ return AddressSecpPubKey(self.votingKey().pub.serializeCompressed(), self.net).string() + + def addPool(self, pool): + """ + Add the specified pool to the list of stakepools we can use. + + Args: + pool (vsp.VotingServiceProvider): The stake pool object. + """ + assert isinstance(pool, VotingServiceProvider) + # If this a new pool, give it an ID one more than the highest. + if pool.ID < 0: + pool.ID = 0 + if len(self.stakePools) > 0: + pool.ID = max([p.ID for p in self.stakePools]) + 1 + self.stakePools = [pool] + [p for p in self.stakePools if p.ID != + pool.ID] + def setPool(self, pool): """ - Set the specified pool as the default. + Set the specified pool for use. Args: pool (vsp.VotingServiceProvider): The stake pool object. """ assert isinstance(pool, VotingServiceProvider) - self.stakePools = [pool] + [p for p in self.stakePools if p.apiKey != pool.apiKey] bc = self.blockchain addr = pool.purchaseInfo.ticketAddress for txid in bc.txsForAddr(addr): @@ -349,33 +370,56 @@ def purchaseTickets(self, qty, price): prepare the TicketRequest and KeySource and gather some other account- related information. """ + pool = self.stakePool() + allTxs = [[], []] + + # If accountless, purchase tickets one at a time. + if pool.isAccountless: + for i in range(qty): + # TODO use a new voting address every time. + addr = self.votingAddress() + pool.authorize(addr, self.net) + self.setPool(pool) + self._purchaseTickets(pool, allTxs, 1, price) + # dcrdata needs some time inbetween requests. This should + # probably be randomized to increase privacy anyway. + if qty > 1 and i < qty: + time.sleep(2) + else: + self._purchaseTickets(pool, allTxs, qty, price) + if allTxs[0]: + for tx in allTxs[0]: + # Add the split transactions + self.addMempoolTx(tx) + for txs in allTxs[1]: + # Add all tickets + for tx in txs: + self.addMempoolTx(tx) + # Store the txids. + self.tickets.extend([tx.txid() for tx in txs]) + return allTxs[1] + + def _purchaseTickets(self, pool, allTxs, qty, price): keysource = KeySource( - priv = self.getPrivKeyForAddress, - internal = self.nextInternalAddress, + priv=self.getPrivKeyForAddress, + internal=self.nextInternalAddress, ) - pool = self.stakePool() pi = pool.purchaseInfo req = TicketRequest( - minConf = 0, - expiry = 0, - spendLimit = int(round(price*qty*1.1*1e8)), # convert to atoms here - poolAddress = pi.poolAddress, - votingAddress = pi.ticketAddress, - ticketFee = 0, # use network default - poolFees = pi.poolFees, - count = qty, - txFee = 0, # use network default + minConf=0, + expiry=0, + spendLimit=int(round(price*qty*1.1*1e8)), # convert to atoms here + poolAddress=pi.poolAddress, + votingAddress=pi.ticketAddress, + ticketFee=0, # use network default + poolFees=pi.poolFees, + count=qty, + txFee=0, # use network default ) txs, spentUTXOs, newUTXOs = self.blockchain.purchaseTickets(keysource, self.getUTXOs, req) - if txs: - # Add the split transactions - self.addMempoolTx(txs[0]) - # Add all tickets - for tx in txs[1]: - self.addMempoolTx(tx) - # Store the txids. - self.tickets.extend([tx.txid() for tx in txs[1]]) - return txs[1] + allTxs[0].append(txs[0]) + allTxs[1].append(txs[1]) + def sync(self, blockchain, signals): """ Synchronize the UTXO set with the server. This should be the first diff --git a/pydecred/vsp.py b/pydecred/vsp.py index 8a62051d..1aa83408 100644 --- a/pydecred/vsp.py +++ b/pydecred/vsp.py @@ -9,6 +9,17 @@ from tinydecred.crypto import crypto from tinydecred.crypto.bytearray import ByteArray + +# joe's test stakepool +# TODO: remove +dcrstakedinner = {'APIEnabled': True, 'APIVersionsSupported': [1, 2, 3], + 'Network': 'testnet', 'URL': 'https://www.dcrstakedinner.com', + 'Launched': 1543421580, 'LastUpdated': 1574655889, + 'Immature': 0, 'Live': 0, 'Voted': 0, 'Missed': 0, + 'PoolFees': 0.5, 'ProportionLive': 0, 'ProportionMissed': 0, + 'UserCount': 0, 'UserCountActive': 0, 'Version': '1.5.0-pre+dev'} + + def resultIsSuccess(res): """ JSON-decoded stake pool responses have a common base structure that enables @@ -140,23 +151,36 @@ def __init__(self, url, apiKey): # The signingAddress (also called a votingAddress in other contexts) is # the P2SH 1-of-2 multi-sig address that spends SSTX outputs. self.signingAddress = None + self.isAccountless = apiKey == "accountless" + self.ID = -1 self.apiKey = apiKey self.lastConnection = 0 self.purchaseInfo = None self.stats = None self.err = None + self.votingAddresses = [] def __tojson__(self): return { "url": self.url, "apiKey": self.apiKey, "purchaseInfo": self.purchaseInfo, "stats": self.stats, + "ID": self.ID, + "votingAddresses": self.votingAddresses, } @staticmethod def __fromjson__(obj): sp = VotingServiceProvider(obj["url"], obj["apiKey"]) + # TODO: These if's can be removed. They are here in case these keys do + # not exist yet. + if "ID" in obj: + sp.ID = obj["ID"] + else: + sp.ID = -1 + sp.purchaseInfo = obj["purchaseInfo"] sp.stats = obj["stats"] + sp.votingAddresses = obj["votingAddresses"] return sp @staticmethod def providers(net): @@ -170,6 +194,8 @@ def providers(net): list(object): The vsp list. """ vsps = tinyhttp.get("https://api.decred.org/?c=gsd") + # TODO remove adding dcrstakedinner + vsps["stakedinner"] = dcrstakedinner network = "testnet" if net.Name == "testnet3" else net.Name return [vsp for vsp in vsps.values() if vsp["Network"] == network] def apiPath(self, command): @@ -191,6 +217,15 @@ def headers(self): object: The headers as a Python object. """ return {"Authorization": "Bearer %s" % self.apiKey} + def accountlessData(self, addr): + """ + Make the API request headers. + + Returns: + object: The headers as a Python object. + """ + return {"UserPubKeyAddr": "%s" % addr} + def validate(self, addr): """ Validate performs some checks that the PurchaseInfo provided by the @@ -218,6 +253,7 @@ def validate(self, addr): break if not found: raise Exception("signing pubkey not found in redeem script") + self.votingAddresses.append(addr.string()) def authorize(self, address, net): """ Authorize the stake pool for the provided address and network. Exception @@ -233,7 +269,7 @@ def authorize(self, address, net): # First try to get the purchase info directly. self.net = net try: - self.getPurchaseInfo() + self.getPurchaseInfo(address) self.validate(address) except Exception as e: alreadyRegistered = isinstance(self.err, dict) and "code" in self.err and self.err["code"] == 9 @@ -244,11 +280,11 @@ def authorize(self, address, net): data = { "UserPubKeyAddr": address } res = tinyhttp.post(self.apiPath("address"), data, headers=self.headers(), urlEncode=True) if resultIsSuccess(res): - self.getPurchaseInfo() + self.getPurchaseInfo(address) self.validate(address) else: raise Exception("unexpected response from 'address': %s" % repr(res)) - def getPurchaseInfo(self): + def getPurchaseInfo(self, addr): """ Get the purchase info from the stake pool API. @@ -257,7 +293,15 @@ def getPurchaseInfo(self): """ # An error is returned if the address isn't yet set # {'status': 'error', 'code': 9, 'message': 'purchaseinfo error - no address submitted', 'data': None} - res = tinyhttp.get(self.apiPath("getpurchaseinfo"), headers=self.headers()) + if self.isAccountless: + # Accountless vsp gets purchaseinfo from api/purchaseticket + # endpoint. + res = tinyhttp.post(self.apiPath("purchaseticket"), + self.accountlessData(addr), urlEncode=True) + else: + # An error is returned if the address isn't yet set + # {'status': 'error', 'code': 9, 'message': 'purchaseinfo error - no address submitted', 'data': None} + res = tinyhttp.get(self.apiPath("getpurchaseinfo"), headers=self.headers()) if resultIsSuccess(res): pi = PurchaseInfo(res["data"]) # check the script hash diff --git a/ui/screens.py b/ui/screens.py index 127ebf7c..95600466 100644 --- a/ui/screens.py +++ b/ui/screens.py @@ -1242,6 +1242,8 @@ def __init__(self, app, callback): validated. """ super().__init__(app) + self.isAccountless = False + self.accountlessPools = [] self.isPoppable = True self.canGoHome = True self.callback = callback @@ -1267,6 +1269,7 @@ def __init__(self, app, callback): self.layout.addWidget(wgt) self.keyIp = edit = QtWidgets.QLineEdit() edit.setPlaceholderText("API key") + self.edit = edit self.keyIp.setContentsMargins(0, 0, 0, 30) self.layout.addWidget(edit) edit.returnPressed.connect(self.authPool) @@ -1314,6 +1317,14 @@ def __init__(self, app, callback): wgt, _ = Q.makeSeries(Q.HORIZONTAL, btn1, Q.STRETCH, btn2) self.layout.addWidget(wgt) + def refreshAccountless(self): + if self.isAccountless: + self.edit.hide() + else: + self.edit.show() + self.randomizePool() + + def getPools(self): """ Get the current master list of VSPs from decred.org. @@ -1344,6 +1355,7 @@ def setPools(self, pools): # instead of checking the network config's Name attribute. if cfg.net.Name == "mainnet": self.pools = [p for p in pools if tNow - p["LastUpdated"] < 86400 and self.scorePool(p) > 95] + self.accountlessPools = [p for p in pools if 3 in p["APIVersionsSupported"]] self.randomizePool() def randomizePool(self, e=None): @@ -1352,7 +1364,10 @@ def randomizePool(self, e=None): is based purely on voting record, e.g. voted/(voted+missed). The sorting and some initial filtering was already performed in setPools. """ - pools = self.pools + if self.isAccountless: + pools = self.accountlessPools + else: + pools = self.pools count = len(pools) if count == 0: log.warn("no stake pools returned from server") @@ -1392,25 +1407,31 @@ def authPool(self): err("invalid pool address: %s" % url) return apiKey = self.keyIp.text() - if not apiKey: - err("empty API key") - return + if self.isAccountless: + apiKey = "accountless" + else: + if not apiKey: + err("empty API key") + return pool = VotingServiceProvider(url, apiKey) + def registerPool(wallet): try: addr = wallet.openAccount.votingAddress() pool.authorize(addr, cfg.net) app.appWindow.showSuccess("pool authorized") - wallet.openAccount.setPool(pool) + wallet.openAccount.addPool(pool) + if not self.isAccountless: + wallet.openAccount.setPool(pool) wallet.save() - # Notify that vote data should be updated. - self.app.emitSignal(ui.PURCHASEINFO_SIGNAL) return True except Exception as e: err("pool authorization failed") log.error("pool registration error: %s" % formatTraceback(e)) return False app.withUnlockedWallet(registerPool, self.callback) + + def showAll(self, e=None): """ Connected to the "see all" button clicked signal. Open the fu @@ -1691,9 +1712,13 @@ def __init__(self, app, poolScreen): self.nextPg) self.layout.addWidget(self.pagination) - btn = app.getButton(SMALL, "add new acccount") + btn = app.getButton(SMALL, "add new account") btn.clicked.connect(self.addClicked) self.layout.addWidget(btn) + + btn = app.getButton(SMALL, "add new accountless") + btn.clicked.connect(self.addAccountlessClicked) + self.layout.addWidget(btn) def stacked(self): """ stacked is called on screens when stacked by the TinyDialog. @@ -1736,7 +1761,7 @@ def setPools(self): if len(pools) == 0: return # Refresh purchase info - pools[0].getPurchaseInfo() + pools[0].getPurchaseInfo(pools[0].votingAddresses[len(pools[0].votingAddresses) - 1]) # Notify that vote data should be updated. self.app.emitSignal(ui.PURCHASEINFO_SIGNAL) self.pages = [pools[i*2:i*2+2] for i in range((len(pools)+1)//2)] @@ -1753,7 +1778,10 @@ def setWidgets(self, pools): """ Q.clearLayout(self.poolsLyt, delete=True) for pool in pools: - ticketAddr = pool.purchaseInfo.ticketAddress + if pool.isAccountless: + ticketAddr = "accountless" + else: + ticketAddr = pool.purchaseInfo.ticketAddress urlLbl = Q.makeLabel(pool.url, 16) addrLbl = Q.makeLabel(ticketAddr, 14) wgt, lyt = Q.makeSeries(Q.VERTICAL, @@ -1772,7 +1800,9 @@ def selectActivePool(self, pool): pool (VotingServiceProvider): The new active pool. """ self.app.appWindow.showSuccess("new pool selected") - self.app.wallet.selectedAccount.setPool(pool) + self.app.wallet.selectedAccount.addPool(pool) + if not pool.isAccountless: + self.app.wallet.selectedAccount.setPool(pool) self.setPools() def addClicked(self, e=None): @@ -1780,6 +1810,17 @@ def addClicked(self, e=None): The clicked slot for the add pool button. Stacks the pool screen. """ self.app.appWindow.pop(self) + self.poolScreen.isAccountless = False + self.poolScreen.refreshAccountless() + self.app.appWindow.stack(self.poolScreen) + + def addAccountlessClicked(self, e=None): + """ + The clicked slot for the add pool button. Stacks the pool screen. + """ + self.app.appWindow.pop(self) + self.poolScreen.isAccountless = True + self.poolScreen.refreshAccountless() self.app.appWindow.stack(self.poolScreen) class ConfirmScreen(Screen):