diff --git a/pydecred/account.py b/pydecred/account.py index 0964771b..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 @@ -424,4 +468,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/dcrdata.py b/pydecred/dcrdata.py index 36f5bf94..ad5f3ac8 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. @@ -167,7 +168,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 +387,93 @@ 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"], + ) + + +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"]], + ) + + +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"]], + ) + + class UTXO(object): """ The UTXO is part of the wallet API. BlockChains create and parse UTXO @@ -650,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. @@ -665,6 +763,7 @@ def subscribeAddresses(self, addrs, receiver=None): elif self.addressReceiver == None: raise Exception("must set receiver to subscribe to addresses") self.dcrdata.subscribeAddresses(addrs) + def processNewUTXO(self, utxo): """ Processes an as-received blockchain utxo. diff --git a/pydecred/vsp.py b/pydecred/vsp.py index 15ecc76b..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 @@ -287,6 +331,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..4c93aff0 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: 16px; + 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; @@ -545,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 0461f7b5..95600466 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(TINY, "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,23 @@ def stacked(self): def stackAccounts(self): self.app.appWindow.stack(self.accountScreen) + def stackAgendas(self): + 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): """ Get the current ticket stats and update the display. @@ -1217,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 @@ -1242,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) @@ -1289,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. @@ -1319,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): @@ -1327,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") @@ -1367,16 +1407,22 @@ 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() return True except Exception as e: @@ -1384,6 +1430,8 @@ def registerPool(wallet): 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 @@ -1403,6 +1451,220 @@ 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 + + # Currently shown agenda dropdowns are saved here. + self.dropdowns = [] + self.pages = [] + 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.setBlockchain) + + self.accountScreen = accountScreen + 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) + + 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, + prevPg, + Q.STRETCH, + pgNum, + Q.STRETCH, + 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 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.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]) + self.pagination.setVisible(len(self.pages) > 1) + + 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 + 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 + 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.voteSet = True + + 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) + 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) + + # 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.lastIndex = 0 + choicesDropdown.activated.connect(self.onChooseChoiceFunc(choicesDropdown)) + + choicesWgt, _ = Q.makeSeries(Q.HORIZONTAL, choicesDropdown) + wgt, lyt = Q.makeSeries(Q.VERTICAL, nameWgt, descriptionLbl, choicesWgt) + wgt.setMinimumWidth(360) + lyt.setContentsMargins(5, 5, 5, 5) + Q.addDropShadow(wgt) + self.agendasLyt.addWidget(wgt, 1) + + def onChooseChoiceFunc(self, dropdown): + """ + 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") + 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) + return func + + + class PoolAccountScreen(Screen): """ A screen that lists currently known VSP accounts, and allows adding new @@ -1450,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. @@ -1494,6 +1760,10 @@ def setPools(self): pools = acct.stakePools if len(pools) == 0: return + # Refresh purchase info + 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)] self.page = 0 self.setWidgets(self.pages[0]) @@ -1508,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, @@ -1527,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): @@ -1535,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): @@ -1645,4 +1931,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..196d5ce3 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" +PURCHASEINFO_SIGNAL = "purchaseinfo_signal"