diff --git a/pydecred/account.py b/pydecred/account.py index 0964771b..36090522 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,29 @@ 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,11 +368,40 @@ 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, @@ -367,15 +415,9 @@ def purchaseTickets(self, qty, price): 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 +466,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..88a1aa67 100644 --- a/pydecred/vsp.py +++ b/pydecred/vsp.py @@ -9,6 +9,16 @@ 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 @@ -125,7 +135,7 @@ class VotingServiceProvider(object): the VSP API. VotingServiceProvider is JSON-serializable if used with tinyjson, so can be stored as part of an Account in the wallet. """ - def __init__(self, url, apiKey): + def __init__(self, url, apiKey, isAccountless, ID=-1): """ Args: url (string): The stake pool URL. @@ -140,6 +150,8 @@ 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 = isAccountless + self.ID = ID self.apiKey = apiKey self.lastConnection = 0 self.purchaseInfo = None @@ -151,10 +163,19 @@ def __tojson__(self): "apiKey": self.apiKey, "purchaseInfo": self.purchaseInfo, "stats": self.stats, + "isAccountless": self.isAccountless, + "ID": self.ID, } @staticmethod def __fromjson__(obj): - sp = VotingServiceProvider(obj["url"], obj["apiKey"]) + sp = VotingServiceProvider(obj["url"], obj["apiKey"], + False, -1) + # TODO: These if's can be removed. They are here in case these keys do + # not exist yet. + if "isAccountless" in obj: + sp.isAccountless = obj["isAccountless"] + if "ID" in obj: + sp.ID = obj["ID"] sp.purchaseInfo = obj["purchaseInfo"] sp.stats = obj["stats"] return sp @@ -170,6 +191,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 +214,14 @@ 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 @@ -233,7 +264,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,20 +275,26 @@ 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. Returns: PurchaseInfo: The PurchaseInfo object. """ - # 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 0461f7b5..c7cb5246 100644 --- a/ui/screens.py +++ b/ui/screens.py @@ -1209,7 +1209,7 @@ def poolAuthed(self, res): window.stack(self) class PoolScreen(Screen): - def __init__(self, app, callback): + def __init__(self, app, callback, isAccountless=False): """ Args: app (TinyDecred): The TinyDecred application instance. @@ -1217,10 +1217,12 @@ def __init__(self, app, callback): validated. """ super().__init__(app) + self.isAccountless = isAccountless self.isPoppable = True self.canGoHome = True self.callback = callback self.pools = [] + self.accountlessPools = [] self.poolIdx = -1 self.app.registerSignal(ui.BLOCKCHAIN_CONNECTED, self.getPools) self.wgt.setMinimumWidth(400) @@ -1243,6 +1245,7 @@ def __init__(self, app, callback): self.keyIp = edit = QtWidgets.QLineEdit() edit.setPlaceholderText("API key") self.keyIp.setContentsMargins(0, 0, 0, 30) + self.edit = edit self.layout.addWidget(edit) edit.returnPressed.connect(self.authPool) @@ -1289,6 +1292,13 @@ 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 +1329,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 +1338,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,23 +1381,29 @@ def authPool(self): err("invalid pool address: %s" % url) return apiKey = self.keyIp.text() - if not apiKey: - err("empty API key") - return - pool = VotingServiceProvider(url, apiKey) - def registerPool(wallet): + if self.isAccountless: + apiKey = "accountless" + else: + if not apiKey: + err("empty API key") + return + pool = VotingServiceProvider(url, apiKey, self.isAccountless) + + def register(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: err("pool authorization failed") log.error("pool registration error: %s" % formatTraceback(e)) return False - app.withUnlockedWallet(registerPool, self.callback) + app.withUnlockedWallet(register, self.callback) def showAll(self, e=None): """ Connected to the "see all" button clicked signal. Open the fu @@ -1450,9 +1470,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. @@ -1508,7 +1532,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 +1554,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,8 +1564,20 @@ 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): """ A screen that displays a custom prompt and calls a callback function @@ -1645,4 +1686,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/util/tinyhttp.py b/util/tinyhttp.py index 8558a555..e4103f66 100644 --- a/util/tinyhttp.py +++ b/util/tinyhttp.py @@ -38,4 +38,4 @@ def request(uri, postData=None, headers=None, urlEncode=False, supressErr=False) except Exception as e: raise Exception("JSONError", "Failed to decode server response from path %s: %s : %s" % (uri, raw, formatTraceback(e))) except Exception as e: - raise Exception("RequestError", "Error encountered in requesting path %s: %s" % (uri, formatTraceback(e))) \ No newline at end of file + raise Exception("RequestError", "Error encountered in requesting path %s: %s" % (uri, formatTraceback(e)))