diff --git a/accounts.py b/accounts.py index 555d04bb..b80d3f4b 100644 --- a/accounts.py +++ b/accounts.py @@ -26,6 +26,12 @@ CrazyAddress = "CRAZYADDRESS" +def filterCrazyAddress(addrs): + return [a for a in addrs if a != CrazyAddress] + +# DefaultGapLimit is the default unused address gap limit defined by BIP0044. +DefaultGapLimit = 20 + log = helpers.getLogger("TCRYP") # , logLvl=0) class CoinSymbols: @@ -186,11 +192,18 @@ def __init__(self, pubKeyEncrypted, privKeyEncrypted, name, coinID, netID): self.netID = netID self.net = None setNetwork(self) - self.lastExternalIndex = -1 - self.lastInternalIndex = -1 + # For external addresses, the cursor can sit on the last seen address, + # so start the lastSeen at the 0th external address. This is necessary + # because the currentAddress method grabs the address at the current + # cursor position, rather than the next. + self.lastSeenExt = 0 + # For internal addresses, the cursor can sit below zero, since the + # addresses are always retrieved with with nextInternalAddress. + self.lastSeenInt = -1 self.externalAddresses = [] self.internalAddresses = [] - self.cursor = 0 + self.cursorExt = 0 + self.cursorInt = 0 self.balance = Balance() # maps a txid to a MsgTx for a transaction suspected of being in # mempool. @@ -205,21 +218,22 @@ def __init__(self, pubKeyEncrypted, privKeyEncrypted, name, coinID, netID): self.privKey = None # The private extended key. self.extPub = None # The external branch public extended key. self.intPub = None # The internal branch public extended key. + self.gapLimit = DefaultGapLimit def __tojson__(self): return { "pubKeyEncrypted": self.pubKeyEncrypted, "privKeyEncrypted": self.privKeyEncrypted, - "lastExternalIndex": self.lastExternalIndex, - "lastInternalIndex": self.lastInternalIndex, "name": self.name, "coinID": self.coinID, "netID": self.netID, "externalAddresses": self.externalAddresses, "internalAddresses": self.internalAddresses, - "cursor": self.cursor, + "cursorExt": self.cursorExt, + "cursorInt": self.cursorInt, "txs": self.txs, "utxos": self.utxos, "balance": self.balance, + "gapLimit": self.gapLimit, } @staticmethod def __fromjson__(obj, cls=None): @@ -231,14 +245,16 @@ def __fromjson__(obj, cls=None): obj["coinID"], obj["netID"], ) - acct.lastExternalIndex = obj["lastExternalIndex"] - acct.lastInternalIndex = obj["lastInternalIndex"] acct.externalAddresses = obj["externalAddresses"] acct.internalAddresses = obj["internalAddresses"] - acct.cursor = obj["cursor"] + acct.cursorExt = obj["cursorExt"] + acct.cursorInt = obj["cursorInt"] acct.txs = obj["txs"] acct.utxos = obj["utxos"] acct.balance = obj["balance"] + acct.gapLimit = obj["gapLimit"] + acct.lastSeenExt = acct.lastSeen(acct.externalAddresses) + acct.lastSeenInt = acct.lastSeen(acct.internalAddresses) setNetwork(acct) return acct def addrTxs(self, addr): @@ -376,6 +392,21 @@ def addTxid(self, addr, txid): txids = self.txs[addr] if txid not in txids: txids.append(txid) + try: + extIdx = self.externalAddresses.index(addr) + if extIdx > self.lastSeenExt: + diff = extIdx - self.lastSeenExt + self.lastSeenExt = extIdx + self.cursorExt = max(0, self.cursorExt-diff) + except ValueError: + try: + intIdx = self.internalAddresses.index(addr) + if intIdx > self.lastSeenInt: + diff = intIdx - self.lastSeenInt + self.lastSeenInt = intIdx + self.cursorInt = max(0, self.cursorInt-diff) + except ValueError: + raise Exception("attempting to add transaction %s for unknown address %s" % (txid, addr)) def confirmTx(self, tx, blockHeight): """ Confirm a transaction. Sets height for any unconfirmed UTXOs in the @@ -413,70 +444,108 @@ def calcBalance(self, tipHeight): self.balance.total = tot self.balance.available = avail return self.balance - def generateNextPaymentAddress(self): + def nextBranchAddress(self, branchKey, branchAddrs): """ Generate a new address and add it to the list of external addresses. Does not move the cursor. + Args: + branchKey (ExtendedKey): The branch extended public key. + branchAddrs (list(string)): The current list of branch addresses. + Returns: addr (str): Base-58 encoded address. """ - if len(self.externalAddresses) != self.lastExternalIndex + 1: - raise Exception("index-address length mismatch") - idx = self.lastExternalIndex + 1 - try: - addr = self.extPub.deriveChildAddress(idx, self.net) - except crypto.ParameterRangeError: - log.warning("crazy address generated") - addr = CrazyAddress - self.externalAddresses.append(addr) - self.lastExternalIndex = idx + for i in range(3): + try: + addr = branchKey.deriveChildAddress(len(branchAddrs), self.net) + branchAddrs.append(addr) + return addr + except crypto.ParameterRangeError: + log.warning("crazy address generated") + addr = CrazyAddress + branchAddrs.append(addr) + continue + raise Exception("failed to generate new address") + def nextExternalAddress(self): + """ + Return a new external address by advancing the cursor. + + Returns: + addr (str): Base-58 encoded address. + """ + extAddrs = self.externalAddresses + addr = CrazyAddress + # Though unlikely, if an out or range key is generated, the account will + # generate an additional address + while addr == CrazyAddress: + # gap policy is to wrap. Wrapping brings the cursor back to index 1, + # since the index zero is the last seen address. + self.cursorExt += 1 + if self.cursorExt > self.gapLimit: + self.cursorExt = 1 + idx = self.lastSeenExt + self.cursorExt + while len(extAddrs) < idx + 1: + self.nextBranchAddress(self.extPub, extAddrs) + addr = extAddrs[idx] return addr - def getNextPaymentAddress(self): + def nextInternalAddress(self): """ - Get the next address after the cursor and move the cursor. + Return a new internal address Returns: addr (str): Base-58 encoded address. """ - self.cursor += 1 - while self.cursor >= len(self.externalAddresses): - self.generateNextPaymentAddress() - return self.externalAddresses[self.cursor] - def generateGapAddresses(self, gap): + intAddrs = self.internalAddresses + addr = CrazyAddress + # Though unlikely, if an out or range key is generated, the account will + # generate an additional address + while addr == CrazyAddress: + # gap policy is to wrap. Wrapping brings the cursor back to index 1, + # since the index zero is the last seen address. + self.cursorInt += 1 + if self.cursorInt > self.gapLimit: + self.cursorInt = 1 + idx = self.lastSeenInt + self.cursorInt + while len(intAddrs) < idx + 1: + self.nextBranchAddress(self.intPub, intAddrs) + addr = intAddrs[idx] + return addr + def lastSeen(self, addrs): + """ + Find the index of the last seen address in the list of addresses. + The last seen address is taken as the last address for which there is an + entry in the self.txs dict. + + Args: + addrs (list(string)): The list of addresses. + + Returns: + int: The highest index of all seen addresses in the list. + """ + lastSeen = -1 + for i, addr in enumerate(addrs): + if addr in self.txs: + lastSeen = i + return lastSeen + def generateGapAddresses(self): """ Generate addresses up to gap addresses after the cursor. Do not move the cursor. """ if self.extPub is None: log.warning("attempting to generate gap addresses on a closed account") - highest = 0 - for addr in self.txs: - try: - highest = max(highest, self.externalAddresses.index(addr)) - except ValueError: # Not found - continue - tip = highest + gap - while len(self.externalAddresses) < tip: - self.generateNextPaymentAddress() - def getChangeAddress(self): - """ - Return a new change address. - - Returns: - addr (str): Base-58 encoded address. - """ - if len(self.internalAddresses) != self.lastInternalIndex + 1: - raise Exception("index-address length mismatch while generating change address") - idx = self.lastInternalIndex + 1 - try: - addr = self.intPub.deriveChildAddress(idx, self.net) - except crypto.ParameterRangeError: - log.warning("crazy address generated") - addr = CrazyAddress - self.internalAddresses.append(addr) - self.lastInternalIndex = idx - return addr + newAddrs = [] + extAddrs, intAddrs = self.externalAddresses, self.internalAddresses + minExtLen = self.lastSeenExt + self.gapLimit + 1 + while len(extAddrs) < minExtLen: + self.nextBranchAddress(self.extPub, extAddrs) + newAddrs.append(extAddrs[len(extAddrs)-1]) + minIntLen = self.lastSeenInt + self.gapLimit + 1 + while len(intAddrs) < minIntLen: + self.nextBranchAddress(self.intPub, self.internalAddresses) + newAddrs.append(intAddrs[len(intAddrs)-1]) + return newAddrs def allAddresses(self): """ Get the list of all known addresses for this account. @@ -484,8 +553,8 @@ def allAddresses(self): Returns: list(str): A list of base-58 encoded addresses. """ - return self.internalAddresses + self.externalAddresses - def addressesOfInterest(self): + return filterCrazyAddress(self.internalAddresses + self.externalAddresses) + def watchAddrs(self): """ Get the list of all known addresses for this account. @@ -493,20 +562,22 @@ def addressesOfInterest(self): list(str): A list of base-58 encoded addresses. """ a = set() - for utxo in self.utxoscan(): - a.add(utxo.address) - ext = self.externalAddresses - for i in range(max(self.cursor - 10, 0), self.cursor+1): - a.add(ext[i]) - return list(a) - def paymentAddress(self): + a = a.union((utxo.address for utxo in self.utxoscan())) + a = a.union(self.externalAddresses) + a = a.union((a for a in self.internalAddresses if a not in self.txs)) + return filterCrazyAddress(a) + def unseenAddrs(self): + return filterCrazyAddress( + [a for a in self.internalAddresses if a not in self.txs] + + [a for a in self.externalAddresses if a not in self.txs]) + def currentAddress(self): """ Get the external address at the cursor. The cursor is not moved. Returns: addr (str): Base-58 encoded address. """ - return self.externalAddresses[self.cursor] + return self.externalAddresses[self.lastSeenExt+self.cursorExt] def privateExtendedKey(self, pw): """ Decode the private extended key using the provided SecretKey. @@ -810,8 +881,8 @@ def createNewAccountManager(seed, pubPassphrase, privPassphrase, chainParams, co DEFAULT_ACCOUNT_NAME, CoinSymbols.decred, chainParams.Name) # Open the account zerothAccount.open(cryptoKeyPriv) - # Create the first payment address - zerothAccount.generateNextPaymentAddress() + # Create one external address + zerothAccount.generateGapAddresses() # Close the account to zero the key zerothAccount.close() @@ -883,7 +954,7 @@ def test_accounts(self): self.assertEqual(addr1, addr2) acct = am.openAccount(0, pw) for n in range(20): - acct.getNextPaymentAddress() + acct.nextExternalAddress() v = 5 satoshis = v*1e8 txid = "abcdefghijkl" @@ -917,13 +988,6 @@ def test_accounts(self): self.assertEqual(utxocount(), 0) def test_newmaster(self): kpriv = newMaster(testSeed, nets.mainnet) - # --extKey: f2418d00085be520c6449ddb94b25fe28a1944b5604193bd65f299168796f862 - # --kpub: 0317a47499fb2ef0ff8dc6133f577cd44a5f3e53d2835ae15359dbe80c41f70c9b - # --kpub_branch0: 02dfed559fddafdb8f0041cdd25c4f9576f71b0e504ce61837421c8713f74fb33c - # --kpub_branch0_child1: 03745417792d529c66980afe36f364bee6f85a967bae117bc4d316b77e7325f50c - # --kpriv_branch0: 6469a8eb3ed6611cc9ee4019d44ec545f3174f756cc41f9867500efdda742dd9 - # --kpriv_branch0_child1: fb8efe52b3e4f31bc12916cbcbfc0e84ef5ebfbceb7197b8103e8009c3a74328 - self.assertEqual(kpriv.key.hex(), "f2418d00085be520c6449ddb94b25fe28a1944b5604193bd65f299168796f862") kpub = kpriv.neuter() self.assertEqual(kpub.key.hex(), "0317a47499fb2ef0ff8dc6133f577cd44a5f3e53d2835ae15359dbe80c41f70c9b") @@ -943,7 +1007,78 @@ def test_change_addresses(self): # acct = acctManager.account(0) acct = acctManager.openAccount(0, pw) for i in range(10): - acct.getChangeAddress() - -if __name__ == "__main__": - pass \ No newline at end of file + acct.nextInternalAddress() + def test_gap_handling(self): + internalAddrs = [ + "DskHpgbEb6hqkuHchHhtyojpehFToEtjQSo", + "Dsm4oCLnLraGDedfU5unareezTNT75kPbRb", + "DsdN6A9bWhKKJ7PAGdcDLxQrYPKEnjnDv2N", + "Dsifz8eRHvQrfaPXgHHLMDHZopFJq2pBPU9", + "DsmmzJiTTmpafdt2xx7LGk8ZW8cdAKe53Zx", + "DsVB47P4N23PK5C1RyaJdqkQDzVuCDKGQbj", + "DsouVzScdUUswvtCAv6nxRzi2MeufpWnELD", + "DsSoquT5SiDPfksgnveLv3r524k1v8RamYm", + "DsbVDrcfhbdLy4YaSmThmWN47xhxT6FC8XB", + "DsoSrtGYKruQLbvhA92xeJ6eeuAR1GGJVQA", + ] + + externalAddrs = [ + "DsmP6rBEou9Qr7jaHnFz8jTfrUxngWqrKBw", + "DseZQKiWhN3ceBwDJEgGhmwKD3fMbwF4ugf", + "DsVxujP11N72PJ46wgU9sewptWztYUy3L7o", + "DsYa4UBo379cRMCTpDLxYVfpMvMNBdAGrGS", + "DsVSEmQozEnsZ9B3D4Xn4H7kEedDyREgc18", + "DsifDp8p9mRocNj7XNNhGAsYtfWhccc2cry", + "DsV78j9aF8NBwegbcpPkHYy9cnPM39jWXZm", + "DsoLa9Rt1L6qAVT9gSNE5F5XSDLGoppMdwC", + "DsXojqzUTnyRciPDuCFFoKyvQFd6nQMn7Gb", + "DsWp4nShu8WxefgoPej1rNv4gfwy5AoULfV", + ] + + global DefaultGapLimit + gapLimit = DefaultGapLimit = 5 + pw = "abc".encode() + am = createNewAccountManager(testSeed, bytearray(0), pw, nets.mainnet) + account = am.openAccount(0, pw) + account.gapLimit = gapLimit + listsAreEqual = lambda a, b: len(a) == len(b) and all(x == y for x,y in zip(a,b)) + self.assertTrue(listsAreEqual(account.internalAddresses, internalAddrs[:gapLimit])) + # The external branch starts with the "last seen" at the zeroth address, so + # has one additional address to start. + self.assertTrue(listsAreEqual(account.externalAddresses, externalAddrs[:gapLimit+1])) + + account.addTxid(internalAddrs[0], "somerandomtxid") + newAddrs = account.generateGapAddresses() + self.assertEqual(len(newAddrs), 1) + self.assertEqual(newAddrs[0], internalAddrs[5]) + + # The zeroth external address is considered "seen", so this should not + # change anything. + account.addTxid(externalAddrs[0], "somerandomtxid") + newAddrs = account.generateGapAddresses() + self.assertEqual(len(newAddrs), 0) + + # Mark the 1st address as seen. + account.addTxid(externalAddrs[1], "somerandomtxid") + newAddrs = account.generateGapAddresses() + self.assertEqual(len(newAddrs), 1) + self.assertEqual(externalAddrs[1], account.currentAddress()) + + # cursor should be at index 0, last seen 1, max index 6, so calling + # nextExternalAddress 5 time should put the cursor at index 6, which is + # the gap limit. + for i in range(5): + account.nextExternalAddress() + self.assertEqual(account.currentAddress(), externalAddrs[6]) + + # one more should wrap the cursor back to 1, not zero, so the current + # address is lastSeenExt(=1) + cursor(=1) = 2 + a1 = account.nextExternalAddress() + self.assertEqual(account.currentAddress(), externalAddrs[2]) + self.assertEqual(a1, account.currentAddress()) + + # Sanity check that internal addresses are wrapping too. + for i in range(20): + account.nextInternalAddress() + addrs = account.internalAddresses + self.assertEqual(addrs[len(addrs)-1], internalAddrs[5]) diff --git a/examples/create_testnet_wallet.py b/examples/create_testnet_wallet.py index 54126962..267c2268 100644 --- a/examples/create_testnet_wallet.py +++ b/examples/create_testnet_wallet.py @@ -19,4 +19,4 @@ # Print the seed words and an address. print("Mnemonic seed\n-------------") print(" ".join(mnemonicSeed)) -print("Receive DCR at %s" % wallet.paymentAddress()) +print("Receive DCR at %s" % wallet.currentAddress()) diff --git a/pydecred/README.md b/pydecred/README.md index d961bf1e..b8a9f5fd 100644 --- a/pydecred/README.md +++ b/pydecred/README.md @@ -30,8 +30,7 @@ print(json.dumps(bestBlock, indent=4, sort_keys=True)) Because the available dcrdata endpoints can change with new versions, the interface is generated by pulling a list of endpoints from the API itself. The acquired list does not include some endpoints, particularly the Insight API -endpoints. Additional paths can be provided to the constructor in a list of -paths as the `customPaths` parameter. +endpoints. You can print an endpoint guide to the console with `client.endpointGuide()`, diff --git a/pydecred/account.py b/pydecred/account.py index e0911c5f..ed8120f0 100644 --- a/pydecred/account.py +++ b/pydecred/account.py @@ -6,9 +6,17 @@ support. """ -from tinydecred.accounts import Account, EXTERNAL_BRANCH +from tinydecred.accounts import Account from tinydecred.util import tinyjson, helpers from tinydecred.crypto.crypto import AddressSecpPubKey +from tinydecred.pydecred import txscript + +log = helpers.getLogger("DCRACCT") + +# In addition to the standard internal and external branches, we'll have a third +# branch. This should help accomodate upcoming changes to dcrstakepool. See also +# https://github.com/decred/dcrstakepool/pull/514 +STAKE_BRANCH = 2 class KeySource(object): """ @@ -91,6 +99,8 @@ def __init__(self, *a, **k): self.tickets = [] self.stakeStats = TicketStats() self.stakePools = [] + self.blockchain = None + self.signals = None def __tojson__(self): obj = super().__tojson__() return helpers.recursiveUpdate(obj, { @@ -155,17 +165,16 @@ def allAddresses(self): Overload the base class to add the voting address. """ return self.addTicketAddresses(super().allAddresses()) - def addressesOfInterest(self): + def watchAddrs(self): """ Overload the base class to add the voting address. """ - return self.addTicketAddresses(super().addressesOfInterest()) + return self.addTicketAddresses(super().watchAddrs()) def votingKey(self): """ - This may change, but for now, the voting key is from the zeroth - child of the zeroth child of the external branch. + For now, the voting key is the zeroth child """ - return self.privKey.child(EXTERNAL_BRANCH).child(0).child(0).privateKey() + return self.privKey.child(STAKE_BRANCH).child(0).privateKey() def votingAddress(self): """ The voting address is the pubkey address (not pubkey-hash) for the @@ -204,7 +213,60 @@ def ticketStats(self): A getter for the stakeStats. """ return self.stakeStats - def sendToAddress(self, value, address, feeRate, blockchain): + def blockSignal(self, sig): + """ + Process a new block from the explorer. + + Arg: + sig (obj or string): The block explorer's json-decoded block + notification. + """ + block = sig["message"]["block"] + for newTx in block["Tx"]: + txid = newTx["TxID"] + # only grab the tx if its a transaction we care about. + if self.caresAboutTxid(txid): + tx = self.blockchain.tx(txid) + self.confirmTx(tx, self.blockchain.tipHeight) + # "Spendable" balance can change as utxo's mature, so update the + # balance at every block. + self.signals.balance(self.calcBalance(self.blockchain.tipHeight)) + def addressSignal(self, addr, txid): + """ + Process an address notification from the block explorer. + + Arg: + sig (obj or string): The block explorer's json-decoded address + notification. + """ + tx = self.blockchain.tx(txid) + self.addTxid(addr, tx.txid()) + + matches = False + # scan the inputs for any spends. + for txin in tx.txIn: + op = txin.previousOutPoint + # spendTxidVout is a no-op if output is unknown + match = self.spendTxidVout(op.txid(), op.index) + if match: + matches += 1 + # scan the outputs for any new UTXOs + for vout, txout in enumerate(tx.txOut): + try: + _, addresses, _ = txscript.extractPkScriptAddrs(0, txout.pkScript, self.net) + except Exception: + # log.debug("unsupported script %s" % txout.pkScript.hex()) + continue + # convert the Address objects to strings. + if addr in (a.string() for a in addresses): + utxo = self.blockchain.txVout(txid, vout) + utxo.address = addr + self.addUTXO(utxo) + matches += 1 + if matches: + # signal the balance update + self.signals.balance(self.calcBalance(self.blockchain.tip["height"])) + def sendToAddress(self, value, address, feeRate): """ Send the value to the address. @@ -217,15 +279,15 @@ def sendToAddress(self, value, address, feeRate, blockchain): """ keysource = KeySource( priv = self.getPrivKeyForAddress, - internal = self.getChangeAddress, + internal = self.nextInternalAddress, ) - tx, spentUTXOs, newUTXOs = blockchain.sendToAddress(value, address, keysource, self.getUTXOs, feeRate) + tx, spentUTXOs, newUTXOs = self.blockchain.sendToAddress(value, address, keysource, self.getUTXOs, feeRate) self.addMempoolTx(tx) self.spendUTXOs(spentUTXOs) for utxo in newUTXOs: self.addUTXO(utxo) return tx - def purchaseTickets(self, qty, price, blockchain): + def purchaseTickets(self, qty, price): """ purchaseTickets completes the purchase of the specified tickets. The DecredAccount uses the blockchain to do the heavy lifting, but must @@ -234,7 +296,7 @@ def purchaseTickets(self, qty, price, blockchain): """ keysource = KeySource( priv = self.getPrivKeyForAddress, - internal = self.getChangeAddress, + internal = self.nextInternalAddress, ) pool = self.stakePool() pi = pool.purchaseInfo @@ -249,7 +311,7 @@ def purchaseTickets(self, qty, price, blockchain): count = qty, txFee = 0, # use network default ) - txs, spentUTXOs, newUTXOs = blockchain.purchaseTickets(keysource, self.getUTXOs, req) + txs, spentUTXOs, newUTXOs = self.blockchain.purchaseTickets(keysource, self.getUTXOs, req) if txs: # Add the split transactions self.addMempoolTx(txs[0]) @@ -265,5 +327,51 @@ def purchaseTickets(self, qty, price, blockchain): for utxo in newUTXOs: self.addUTXO(utxo) return txs[1] + def sync(self, blockchain, signals): + """ + Synchronize the UTXO set with the server. This should be the first + action after the account is opened or changed. + """ + self.blockchain = blockchain + self.signals = signals + signals.balance(self.balance) + self.generateGapAddresses() + + # First, look at addresses that have been generated but not seen. Run in + # loop until the gap limit is reached. + requestedTxs = 0 + addrs = self.unseenAddrs() + while addrs: + for addr in addrs: + for txid in blockchain.txsForAddr(addr): + requestedTxs += 1 + self.addTxid(addr, txid) + addrs = self.generateGapAddresses() + log.info("%d address transactions sets fetched" % requestedTxs) + + # start with a search for all known addresses + addresses = self.allAddresses() + + # Until the server stops returning UTXOs, keep requesting more addresses + # to check. + while True: + # Update the account with known UTXOs. + blockchainUTXOs = blockchain.UTXOs(addresses) + if not blockchainUTXOs: + break + self.resolveUTXOs(blockchainUTXOs) + addresses = self.generateGapAddresses() + if not addresses: + break + + # Subscribe to block and address updates. + blockchain.addressReceiver = self.addressSignal + blockchain.subscribeBlocks(self.blockSignal) + watchAddresses = self.watchAddrs() + if watchAddresses: + blockchain.subscribeAddresses(watchAddresses) + # Signal the new balance. + signals.balance(self.calcBalance(self.blockchain.tip["height"])) + return True tinyjson.register(DecredAccount) \ No newline at end of file diff --git a/pydecred/dcrdata.py b/pydecred/dcrdata.py index 759c162c..f591a529 100644 --- a/pydecred/dcrdata.py +++ b/pydecred/dcrdata.py @@ -91,6 +91,14 @@ def getSocketURIs(uri): ps = fmt.format(prot, uri.netloc, "ps") return ws, ps +# To Do: Get the full list here. +InsightPaths = [ + "/tx/send", + "/insight/api/addr/{address}/utxo", + "/insight/api/addr/{address}/txs", + "insight/api/tx/send" +] + class DcrdataClient(object): """ DcrdataClient represents the base node. The only argument to the @@ -99,7 +107,7 @@ class DcrdataClient(object): timeFmt = "%Y-%m-%d %H:%M:%S" rfc3339Z = "%Y-%m-%dT%H:%M:%SZ" - def __init__(self, baseURI, customPaths=None, emitter=None): + def __init__(self, baseURI, emitter=None): """ Build the DcrdataPath tree. """ @@ -113,10 +121,9 @@ def __init__(self, baseURI, customPaths=None, emitter=None): atexit.register(self.close) root = self.root = DcrdataPath() self.listEntries = [] - customPaths = customPaths if customPaths else [] # /list returns a json list of enpoints with parameters in template format, base/A/{param}/B endpoints = http.get(self.baseApi + "/list", headers=GET_HEADERS) - endpoints += customPaths + endpoints += InsightPaths def getParam(part): if part.startswith('{') and part.endswith('}'): @@ -581,11 +588,6 @@ def connect(self): """ self.dcrdata = DcrdataClient( self.datapath, - customPaths=( - "/tx/send", - "/insight/api/addr/{address}/utxo", - "insight/api/tx/send" - ), emitter=self.pubsubSignal, ) self.updateTip() @@ -663,6 +665,17 @@ def UTXOs(self, addrs): ads = addrs[start:end] utxos += [self.processNewUTXO(u) for u in get(ads)] return utxos + def txsForAddr(self, addr): + """ + Get the transaction IDs for the provided address. + + Args: + addrs (string): Base-58 encoded address + """ + addrInfo = self.dcrdata.insight.api.addr.txs(addr) + if "transactions" not in addrInfo: + return [] + return addrInfo["transactions"] def txVout(self, txid, vout): """ Get a UTXO from the outpoint. The UTXO will not have the address set. @@ -889,8 +902,12 @@ def pubsubSignal(self, sig): elif sigType == "subscribeResp": # should check for error. pass + elif sigType == "ping": + # nothing to do here right now. May want to implement a + # auto-reconnect using this signal. + pass else: - raise Exception("unknown signal") + raise Exception("unknown signal %s" % repr(sigType)) except Exception as e: log.error("failed to process pubsub message: %s" % formatTraceback(e)) def changeScript(self, changeAddress): diff --git a/pydecred/tests.py b/pydecred/tests.py index a5af0eeb..37d247f8 100644 --- a/pydecred/tests.py +++ b/pydecred/tests.py @@ -1879,11 +1879,7 @@ def test_calc_signature_hash_reference(self): class TestDcrdata(unittest.TestCase): def client(self, **k): - return dcrdata.DcrdataClient("https://alpha.dcrdata.org", customPaths={ - "/tx/send", - "/insight/api/addr/{address}/utxo", - "insight/api/tx/send" - }, **k) + return dcrdata.DcrdataClient("https://alpha.dcrdata.org", **k) def test_websocket(self): """ "newblock": SigNewBlock, @@ -1904,6 +1900,7 @@ def test_get_block_header(self): blockchain = dcrdata.DcrdataBlockchain(os.path.join(tempDir, "db.db"), mainnet, "https://alpha.dcrdata.org") blockchain.connect() blockchain.blockHeader("298e5cc3d985bfe7f81dc135f360abe089edd4396b86d2de66b0cef42b21d980") + blockchain.close() def test_purchase_ticket(self): from tinydecred.crypto.secp256k1 import curve as Curve from tinydecred.crypto import rando diff --git a/ui/screens.py b/ui/screens.py index 62c27851..0caa6ff2 100644 --- a/ui/screens.py +++ b/ui/screens.py @@ -490,7 +490,7 @@ def showEvent(self, e): """ app = self.app if app.wallet: - address = app.wallet.paymentAddress() + address = app.wallet.currentAddress() self.address.setText(address) def balanceClicked(self): """ @@ -1156,7 +1156,7 @@ def buyTickets(self, wallet, qty): """ tip = self.blockchain.tip["height"] acct = wallet.openAccount - txs = acct.purchaseTickets(qty, self.lastPrice, self.blockchain) + txs = acct.purchaseTickets(qty, self.lastPrice) if txs: wallet.signals.balance(acct.calcBalance(tip)) self.app.home() diff --git a/util/database.py b/util/database.py index 2042678a..92e759e8 100644 --- a/util/database.py +++ b/util/database.py @@ -52,18 +52,20 @@ def __init__(self, database, name, datatypes, unique): self.existsQuery = KVExists.format(tablename=name) # = "SELECT EXISTS(SELECT * FROM kvtable WHERE k = ?);" self.deleteQuery = KVDelete.format(tablename=name) # = "DELETE FROM kvtable WHERE k = ?;" self.countQuery = KVCount.format(tablename=name) # = "SELECT COUNT(*) FROM kvtable;" - self.tid = None + self.conn = None def __enter__(self): """ Create a new connection for a every requesting thread. """ - tid = threadID() - if self.tid != tid: - self.conn = self.database.openDB() - self.tid = tid + if self.conn: + self.conn.close() + self.conn = self.database.openDB() self.open() return self def __exit__(self, xType, xVal, xTB): + if self.conn: + self.conn.close() + self.conn = None pass def __setitem__(self, k, v): cursor = self.conn.cursor() diff --git a/wallet.py b/wallet.py index 144ac369..e9eb9e05 100644 --- a/wallet.py +++ b/wallet.py @@ -6,7 +6,6 @@ from threading import Lock as Mutex from tinydecred.util import tinyjson, helpers from tinydecred.crypto import crypto, mnemonic -from tinydecred.pydecred import txscript from tinydecred.pydecred.account import DecredAccount from tinydecred.accounts import createNewAccountManager @@ -251,78 +250,21 @@ def getNewAddress(self): """ Get the next unused external address. """ - a = self.selectedAccount.getNextPaymentAddress() + a = self.selectedAccount.nextExternalAddress() if self.blockchain: self.blockchain.subscribeAddresses(a) self.save() return a - def paymentAddress(self): + def currentAddress(self): """ Gets the payment address at the cursor. """ - return self.selectedAccount.paymentAddress() + return self.selectedAccount.currentAddress() def balance(self): """ Get the balance of the currently selected account. """ return self.selectedAccount.balance - def blockSignal(self, sig): - """ - Process a new block from the explorer. - - Arg: - sig (obj or string): The block explorer's json-decoded block - notification. - """ - block = sig["message"]["block"] - acct = self.selectedAccount - for newTx in block["Tx"]: - txid = newTx["TxID"] - # only grab the tx if its a transaction we care about. - if acct.caresAboutTxid(txid): - tx = self.blockchain.tx(txid) - acct.confirmTx(tx, self.blockchain.tipHeight) - # "Spendable" balance can change as utxo's mature, so update the - # balance at every block. - self.signals.balance(acct.calcBalance(self.blockchain.tipHeight)) - def addressSignal(self, addr, txid): - """ - Process an address notification from the block explorer. - - Arg: - sig (obj or string): The block explorer's json-decoded address - notification. - """ - acct = self.selectedAccount - - tx = self.blockchain.tx(txid) - acct.addTxid(addr, tx.txid()) - - matches = False - # scan the inputs for any spends. - for txin in tx.txIn: - op = txin.previousOutPoint - # spendTxidVout is a no-op if output is unknown - match = acct.spendTxidVout(op.txid(), op.index) - if match: - matches += 1 - # scan the outputs for any new UTXOs - for vout, txout in enumerate(tx.txOut): - try: - _, addresses, _ = txscript.extractPkScriptAddrs(0, txout.pkScript, acct.net) - except Exception: - # log.debug("unsupported script %s" % txout.pkScript.hex()) - continue - # convert the Address objects to strings. - if addr in (a.string() for a in addresses): - log.debug("found new utxo for %s" % addr) - utxo = self.blockchain.txVout(txid, vout) - utxo.address = addr - acct.addUTXO(utxo) - matches += 1 - if matches: - # signal the balance update - self.signals.balance(acct.calcBalance(self.blockchain.tip["height"])) def sync(self): """ Synchronize the UTXO set with the server. This should be the first @@ -330,27 +272,10 @@ def sync(self): """ acctManager = self.acctManager acct = acctManager.account(0) - gapPolicy = 5 - acct.generateGapAddresses(gapPolicy) - watchAddresses = set() - - # send the initial balance - self.signals.balance(acct.balance) - addresses = acct.allAddresses() - - # Update the account with known UTXOs. - chain = self.blockchain - blockchainUTXOs = chain.UTXOs(addresses) - acct.resolveUTXOs(blockchainUTXOs) - # Subscribe to block and address updates. - chain.subscribeBlocks(self.blockSignal) - watchAddresses = acct.addressesOfInterest() - if watchAddresses: - chain.subscribeAddresses(watchAddresses, self.addressSignal) - # Signal the new balance. - b = acct.calcBalance(self.blockchain.tip["height"]) - self.signals.balance(b) + # send the initial balance. This was the balance the last time the + # wallet was saved, but may be innacurate until sync in complete. + acct.sync(self.blockchain, self.signals) self.save() return True def sendToAddress(self, value, address, feeRate=None): @@ -366,7 +291,7 @@ def sendToAddress(self, value, address, feeRate=None): MsgTx: The newly created transaction on success, `False` on failure. """ acct = self.openAccount - tx = acct.sendToAddress(value, address, feeRate, self.blockchain) + tx = acct.sendToAddress(value, address, feeRate) self.signals.balance(acct.calcBalance(self.blockchain.tip["height"])) self.save() return tx