diff --git a/decred/decred/dcr/account.py b/decred/decred/dcr/account.py index 893e9843..bb1512fd 100644 --- a/decred/decred/dcr/account.py +++ b/decred/decred/dcr/account.py @@ -1733,6 +1733,20 @@ def setNewPool(self, pool): for utxo in bc.UTXOs([addr]): self.addUTXO(utxo) self.updateSpentTickets(checkTxids) + for txid in checkTxids: + tx = None + try: + tx = self.blockchain.tx(txid) + except Exception: + if txid in self.mempool: + tx = self.mempool[txid] + else: + # Should never make it here. + log.error(f"unknown ticket: {txid}") + continue + if tx and tx.isTicket() and txid not in pool.tickets: + pool.tickets.append(txid) + self.vspDB[pool.apiKey] = pool self.updateStakeStats() self.signals.balance(self.calcBalance()) @@ -1880,6 +1894,9 @@ def purchaseTickets(self, qty, price): # Add all tickets for tx in txs[1]: self.addMempoolTx(tx) + if tx.txid() not in pool.tickets: + pool.tickets.append(tx.txid()) + self.vspDB[pool.apiKey] = pool # Store the txids. self.spendUTXOs(spentUTXOs) for utxo in newUTXOs: diff --git a/decred/decred/dcr/txscript.py b/decred/decred/dcr/txscript.py index 98a98588..ba83026a 100644 --- a/decred/decred/dcr/txscript.py +++ b/decred/decred/dcr/txscript.py @@ -294,6 +294,34 @@ # 01000000 00000000 = Apply fees rule SStxRevFractionFlag = 0x4000 +# compactSigSize is the size of a compact signature. It consists of a +# compact signature recovery code byte followed by the R and S components +# serialized as 32-byte big-endian values. 1+32*2 = 65. +# for the R and S components. 1+32+32=65. +compactSigSize = 65 + +# compactSigMagicOffset is a value used when creating the compact signature +# recovery code inherited from Bitcoin and has no meaning, but has been +# retained for compatibility. For historical purposes, it was originally +# picked to avoid a binary representation that would allow compact +# signatures to be mistaken for other components. +compactSigMagicOffset = 27 + +# compactSigCompPubKey is a value used when creating the compact signature +# recovery code to indicate the original public key was compressed. +compactSigCompPubKey = 4 + +# pubKeyRecoveryCodeOddnessBit specifies the bit that indicates the oddess +# of the Y coordinate of the random point calculated when creating a +# signature. +pubKeyRecoveryCodeOddnessBit = 1 << 0 + +# pubKeyRecoveryCodeOverflowBit specifies the bit that indicates the X +# coordinate of the random point calculated when creating a signature was +# >= N, where N is the order of the group. +pubKeyRecoveryCodeOverflowBit = 1 << 1 + + # A couple of hashing functions from the crypto module. mac = crypto.mac hashH = crypto.hashH @@ -2099,25 +2127,34 @@ def signRFC6979(privateKey, inHash): """ N = Curve.N k = nonceRFC6979(privateKey, inHash, ByteArray(b""), ByteArray(b"")) + recoveryCode = 0 inv = crypto.modInv(k, N) - r = Curve.scalarBaseMult(k)[0] % N + kG = Curve.scalarBaseMult(k) + r = kG[0] % N if r == 0: raise DecredError("calculated R is zero") + if kG[1] & 1: + recoveryCode += 1 + if kG[0] > N: + recoveryCode += 4 + e = hashToInt(inHash) s = privateKey.int() * r s += e s *= inv s = s % N - if (N >> 1) > 1: - s = N - s if s == 0: raise DecredError("calculated S is zero") - return Signature(r, s) + if s > N / 2: + s = N - s + recoveryCode ^= 1 + + return Signature(r, s), recoveryCode def putVarInt(val): @@ -2199,6 +2236,32 @@ def addData(data): return b +def signCompact(key, inHash, isCompressedKey): + """ + SignCompact produces a compact signature of the data in hash with the given + private key on the secp256k1 curve. The isCompressedKey parameter specifies + if the given signature should reference a compressed public key or not. + + Compact signature format: + <1-byte compact sig recovery code><32-byte R><32-byte S> + + The compact sig recovery code is the value 27 + public key recovery code + 4 + if the compact signature was created with a compressed public key. + """ + # Create the signature and associated pubkey recovery code and calculate + # the compact signature recovery code. + sig, recoveryCode = signRFC6979(key, inHash) + compactSigRecoveryCode = compactSigMagicOffset + recoveryCode + if isCompressedKey: + compactSigRecoveryCode += compactSigCompPubKey + + # Output <32-byte R><32-byte S>. + b = ByteArray(compactSigRecoveryCode) + b += ByteArray(sig.r, length=32) + b += ByteArray(sig.s, length=32) + return b + + def signatureScript(tx, idx, subscript, hashType, privKey, compress): """ SignatureScript creates an input signature script for tx to spend coins sent @@ -2236,8 +2299,8 @@ def rawTxInSignature(tx, idx, subScript, hashType, key): versions. """ sigHash = calcSignatureHash(subScript, hashType, tx, idx, None) - sig = signRFC6979(key, sigHash).serialize() - return sig + ByteArray(hashType) + sig, _ = signRFC6979(key, sigHash) + return sig.serialize() + ByteArray(hashType) def calcSignatureHash(script, hashType, tx, idx, cachedPrefix): @@ -2717,6 +2780,35 @@ def extractPkScriptAddrs(version, pkScript, netParams): return NonStandardTy, [], 0 +def addrFromSStxPkScrCommitment(pkScript, netParams): + """ + AddrFromSStxPkScrCommitment extracts a P2SH or P2PKH address from a ticket + commitment pkScript. + """ + if len(pkScript) < SStxPKHMinOutSize: + raise DecredError("short read of sstx commit pkscript") + + # The MSB of the encoded amount specifies if the output is P2SH. Since + # it is encoded with little endian, the MSB is in final byte in the encoded + # amount. + # + # This is a faster equivalent of: + # + # amtBytes := script[22:30] + # amtEncoded := binary.LittleEndian.Uint64(amtBytes) + # isP2SH := (amtEncoded & uint64(1<<63)) != 0 + isP2SH = pkScript[29] & 0x80 != 0 + + # The 20 byte PKH or SH. + hashBytes = pkScript[2:22] + + # Return the correct address type. + if isP2SH: + return crypto.newAddressScriptHashFromHash(hashBytes, netParams) + + return crypto.newAddressPubKeyHash(hashBytes, netParams, crypto.STEcdsaSecp256k1) + + def sign(chainParams, tx, idx, subScript, hashType, keysource, sigType): scriptClass, addresses, nrequired = extractPkScriptAddrs( DefaultScriptVersion, subScript, chainParams diff --git a/decred/decred/dcr/vsp.py b/decred/decred/dcr/vsp.py index ac86e1e9..665066dc 100644 --- a/decred/decred/dcr/vsp.py +++ b/decred/decred/dcr/vsp.py @@ -6,6 +6,7 @@ DcrdataClient.endpointList() for available endpoints. """ +import base64 import time from decred import DecredError @@ -159,7 +160,7 @@ class VotingServiceProvider(object): the VSP API. """ - def __init__(self, url, apiKey, netName, purchaseInfo=None): + def __init__(self, url, apiKey, netName, purchaseInfo=None, tickets=None): """ Args: url (string): The stake pool URL. @@ -176,6 +177,8 @@ def __init__(self, url, apiKey, netName, purchaseInfo=None): self.apiKey = apiKey self.net = nets.parse(netName) self.purchaseInfo = purchaseInfo + # a list of ticket txid purchased through this vsp + self.tickets = tickets if tickets else [] self.stats = None self.err = None @@ -184,11 +187,12 @@ def blob(vsp): """Satisfies the encode.Blobber API""" pi = PurchaseInfo.blob(vsp.purchaseInfo) if vsp.purchaseInfo else None return ( - encode.BuildyBytes(0) + encode.BuildyBytes(1) .addData(vsp.url.encode("utf-8")) .addData(vsp.apiKey.encode("utf-8")) .addData(vsp.net.Name.encode("utf-8")) .addData(encode.filterNone(pi)) + .addData(encode.blobStrList(vsp.tickets)) .b ) @@ -196,9 +200,9 @@ def blob(vsp): def unblob(b): """Satisfies the encode.Blobber API""" ver, d = encode.decodeBlob(b) - if ver != 0: + if ver != 1: raise AssertionError("invalid version for VotingServiceProvider %d" % ver) - if len(d) != 4: + if len(d) != 5: raise AssertionError( "wrong number of pushes for VotingServiceProvider. wanted 4, got %d" % len(d) @@ -212,6 +216,7 @@ def unblob(b): apiKey=d[1].decode("utf-8"), netName=d[2].decode("utf-8"), purchaseInfo=pi, + tickets=encode.unblobStrList(d[4]), ) def serialize(self): @@ -259,6 +264,28 @@ def headers(self): """ return {"Authorization": "Bearer %s" % self.apiKey} + def headersV3(self, acct, txid): + """ + Make the API request headers. + + Returns: + object: The headers as a Python object. + """ + now = str(int(time.time())) + txOut = acct.blockchain.tx(txid).txOut[3] + addr = txscript.addrFromSStxPkScrCommitment(txOut.pkScript, acct.net) + msg = "Decred Signed Message:\n" + msgBA = txscript.putVarInt(len(msg)) + msgBA += ByteArray(msg.encode()) + msgBA += txscript.putVarInt(len(now)) + msgBA += ByteArray(now.encode()) + hashedMsg = crypto.hashH(msgBA.bytes()) + sig = self.getSignature(acct, hashedMsg.bytes(), addr) + b64Sig = base64.b64encode(sig.bytes()) + return { + "Authorization": f'TicketAuth SignedTimestamp={now},Signature={b64Sig.decode("utf-8")},TicketHash={txid}' + } + def validate(self, addr): """ Validate performs some checks that the PurchaseInfo provided by the @@ -342,6 +369,14 @@ def getPurchaseInfo(self): self.err = res raise DecredError("unexpected response from 'getpurchaseinfo': %r" % (res,)) + def getSignature(self, acct, msg, addr): + """ + Sign msg with the private key belonging to addr. + """ + privKey = acct.privKeyForAddress(addr.string()) + sig = txscript.signCompact(privKey.key, msg, True) + return sig + def updatePurchaseInfo(self): """ Update purchase info if older than PURCHASE_INFO_LIFE. @@ -362,6 +397,26 @@ def getStats(self): return self.stats raise DecredError("unexpected response from 'stats': %s" % repr(res)) + def setVoteBitsV3(self, voteBits, acct): + """ + Set the vote preference on the VotingServiceProvider. + + Returns: + bool: True on success. DecredError raised on error. + """ + txid = self.tickets[0] + data = {"VoteBits": voteBits} + res = tinyhttp.post( + self.apiPath("voting"), + data, + headers=self.headersV3(acct, txid), + urlEncode=True, + ) + if resultIsSuccess(res): + self.purchaseInfo.voteBits = voteBits + return True + raise DecredError("unexpected response from 'voting': %s" % repr(res)) + def setVoteBits(self, voteBits): """ Set the vote preference on the VotingServiceProvider. diff --git a/tinywallet/tinywallet/screens.py b/tinywallet/tinywallet/screens.py index a05874bf..e6f014a0 100644 --- a/tinywallet/tinywallet/screens.py +++ b/tinywallet/tinywallet/screens.py @@ -2173,7 +2173,7 @@ def func(idx): def changeVote(): app.emitSignal(ui.WORKING_SIGNAL) try: - pools[0].setVoteBits(voteBits) + pools[0].setVoteBitsV3(voteBits, acct) app.appWindow.showSuccess("vote choices updated") dropdown.lastIndex = idx except Exception as e: