diff --git a/pydecred/account.py b/pydecred/account.py index 7bde533d..f48a01b7 100644 --- a/pydecred/account.py +++ b/pydecred/account.py @@ -6,6 +6,8 @@ support. """ +from tinydecred.crypto.bytearray import decodeBA +from tinydecred.crypto import opcode, crypto from tinydecred.wallet.accounts import Account from tinydecred.util import tinyjson, helpers from tinydecred.crypto.crypto import AddressSecpPubKey, CrazyKeyError @@ -422,6 +424,41 @@ def purchaseTickets(self, qty, price): self.tickets.extend([tx.txid() for tx in txs[1]]) return txs[1] + def revokeTickets(self): + """ + Iterate through missed and expired tickets and revoke them. + + Returns: + bool: whether or not an error occured. + """ + revocableTickets = ( + utxo.txid for utxo in self.utxos.values() if utxo.isRevocableTicket() + ) + txs = (self.blockchain.tx(txid) for txid in revocableTickets) + for tx in txs: + redeemHash = crypto.AddressScriptHash( + self.net.ScriptHashAddrID, + txscript.extractStakeScriptHash(tx.txOut[0].pkScript, opcode.OP_SSTX), + ) + redeemScript = next( + ( + decodeBA(p.purchaseInfo.script) + for p in self.stakePools + if p.purchaseInfo.ticketAddress == redeemHash.string() + ), + None, + ) + if not redeemScript: + raise Exception("did not find redeem script for hash %s" % redeemHash) + + keysource = KeySource( + # This will need to change when we start using different + # addresses for voting. + priv=lambda _: self._votingKey, + internal=lambda: "", + ) + self.blockchain.revokeTicket(tx, keysource, redeemScript) + def sync(self, blockchain, signals): """ Synchronize the UTXO set with the server. This should be the first diff --git a/pydecred/dcrdata.py b/pydecred/dcrdata.py index 80988cfa..a047bad4 100644 --- a/pydecred/dcrdata.py +++ b/pydecred/dcrdata.py @@ -16,7 +16,7 @@ import atexit import websocket from tinydecred.util import tinyjson, helpers, database, tinyhttp -from tinydecred.crypto import crypto +from tinydecred.crypto import crypto, opcode from tinydecred.crypto.bytearray import ByteArray from tinydecred.wallet.api import InsufficientFundsError from tinydecred.pydecred import txscript, calc @@ -704,10 +704,19 @@ def isLiveTicket(self): isLiveTicket will return True if this is a live ticket. Returns: - bool. True if this is a live ticket. + bool: True if this is a live ticket. """ return self.tinfo and self.tinfo.status in ("immature", "live") + def isRevocableTicket(self): + """ + Returns True if this is an expired or missed ticket. + + Returns: + bool: True if this is expired or missed ticket. + """ + return self.tinfo and self.tinfo.status in ("expired", "missed") + tinyjson.register(UTXO, "dcr.UTXO") @@ -1626,3 +1635,38 @@ def purchaseTickets(self, keysource, utxosource, req): ) ) return (splitTx, tickets), splitSpent, internalOutputs + + def revokeTicket(self, tx, keysource, redeemScript): + """ + Revoke a ticket by signing the supplied redeem script and broadcasting the raw transaction. + + Args: + tx (object): the msgTx of the ticket purchase. + keysource (object): a KeySource object that holds a function to get the private key used for signing. + redeemScript (byte-like): the 1-of-2 multisig script that delegates voting rights for the ticket. + + Returns: + MsgTx: the signed revocation. + """ + + revocation = txscript.makeRevocation(tx, self.relayFee()) + + signedScript = txscript.signTxOutput( + self.params, + revocation, + 0, + redeemScript, + txscript.SigHashAll, + keysource, + revocation.txIn[0].signatureScript, + crypto.STEcdsaSecp256k1, + ) + + # Append the redeem script to the signature + signedScript += txscript.addData(redeemScript) + revocation.txIn[0].signatureScript = signedScript + + self.broadcast(revocation.txHex()) + + log.info("published revocation %s" % revocation.txid()) + return revocation diff --git a/pydecred/txscript.py b/pydecred/txscript.py index 0eea39ec..3d14a7f2 100644 --- a/pydecred/txscript.py +++ b/pydecred/txscript.py @@ -12,8 +12,11 @@ msgtx, ) # A couple of usefule serialization functions. from tinydecred.crypto import opcode, crypto +from tinydecred.util import helpers from tinydecred.crypto.secp256k1.curve import curve as Curve +log = helpers.getLogger("TXSCRIPT") + HASH_SIZE = 32 SHA256_SIZE = 32 BLAKE256_SIZE = 32 @@ -120,6 +123,18 @@ # - 33 bytes serialized compressed pubkey RedeemP2PKHSigScriptSize = 1 + 73 + 1 + 33 +# RedeemP2SHSigScriptSize is the worst case (largest) serialize size +# of a transaction input script that redeems a P2SH output. +# It is calculated as: +# +# - OP_DATA_73 +# - 73-byte signature +# - OP_DATA_35 +# - OP_DATA_33 +# - 33 bytes serialized compressed pubkey +# - OP_CHECKSIG +RedeemP2SHSigScriptSize = 1 + 73 + 1 + 1 + 33 + 1 + # TicketCommitmentScriptSize is the size of a ticket purchase commitment # script. It is calculated as: # @@ -180,11 +195,51 @@ # fee limits imposed on a ticket. defaultTicketFeeLimits = 0x5800 +# SStxVoteReturnFractionMask extracts the return fraction from a +# commitment output version. +# If after applying this mask &0x003f is given, the entire amount of +# the output is allowed to be spent as fees if the flag to allow fees +# is set. +SStxVoteReturnFractionMask = 0x003F + +# SStxRevReturnFractionMask extracts the return fraction from a +# commitment output version. +# If after applying this mask &0x3f00 is given, the entire amount of +# the output is allowed to be spent as fees if the flag to allow fees +# is set. +SStxRevReturnFractionMask = 0x3F00 + + +# SStxVoteFractionFlag is a bitflag mask specifying whether or not to +# apply a fractional limit to the amount used for fees in a vote. +# 00000000 00000000 = No fees allowed +# 00000000 01000000 = Apply fees rule +SStxVoteFractionFlag = 0x0040 + +# SStxRevFractionFlag is a bitflag mask specifying whether or not to +# apply a fractional limit to the amount used for fees in a vote. +# 00000000 00000000 = No fees allowed +# 01000000 00000000 = Apply fees rule +SStxRevFractionFlag = 0x4000 + # A couple of hashing functions from the crypto module. mac = crypto.mac hashH = crypto.hashH +def canonicalPadding(b): + """ + canonicalPadding checks whether a big-endian encoded integer could + possibly be misinterpreted as a negative number (even though OpenSSL + treats all numbers as unsigned), or if there is any unnecessary + leading zero padding. + """ + if b[0] & 0x80 == 0x80: + raise Exception("negative number") + if len(b) > 1 and b[0] == 0x00 and b[1] & 0x80 != 0x80: + raise Exception("excessive padding") + + class Signature: """ The Signature class represents an ECDSA-algorithm signature. @@ -234,6 +289,102 @@ def serialize(self): b[offset] = sb return b + @staticmethod + def parse(sigBytes, der): + """ + Parse sigBytes to make sure they make up a valid Signature. + + Args: + sigBytes (byte-like): The bytes of the signature. + der (bool): Whether to check for padding and sign. + Returns: + object: the ECDSA Signature. + """ + # minimal message is when both numbers are 1 bytes. adding up to: + # 0x30 + len + 0x02 + 0x01 + + 0x2 + 0x01 + + if len(sigBytes) < 8: + raise Exception("malformed signature: too short") + + # 0x30 + index = 0 + if sigBytes[index] != 0x30: + raise Exception("malformed signature: no header magic") + index += 1 + # length of remaining message + siglen = sigBytes[index] + index += 1 + if siglen + 2 > len(sigBytes): + raise Exception("malformed signature: bad length") + # trim the slice we're working on so we only look at what matters. + sigBytes = sigBytes[: siglen + 2] + + # 0x02 + if sigBytes[index] != 0x02: + raise Exception("malformed signature: no 1st int marker") + index += 1 + + # Length of signature r. + rLen = sigBytes[index] + # must be positive, must be able to fit in another 0x2, + # hence the -3. We assume that the length must be at least one byte. + index += 1 + if rLen <= 0 or rLen > len(sigBytes) - index - 3: + raise Exception("malformed signature: bogus r length") + + # Then r itself. + rBytes = sigBytes[index : index + rLen] + if der: + try: + canonicalPadding(rBytes) + except Exception as e: + raise Exception( + "malformed signature: bogus r padding or sign: {}".format(e) + ) + + index += rLen + # 0x02. length already checked in previous if. + if sigBytes[index] != 0x02: + raise Exception("malformed signature: no 2nd int marker") + index += 1 + + # Length of signature s. + sLen = sigBytes[index] + index += 1 + # s should be the rest of the bytes. + if sLen <= 0 or sLen > len(sigBytes) - index: + raise Exception("malformed signature: bogus S length") + + # Then s itself. + sBytes = sigBytes[index : index + sLen] + if der: + try: + canonicalPadding(rBytes) + except Exception as e: + raise Exception( + "malformed signature: bogus s padding or sign: {}".format(e) + ) + + index += sLen + # sanity check length parsing + if index != len(sigBytes): + raise Exception( + "malformed signature: bad final length %s != %s" % index, len(sigBytes) + ) + + signature = Signature(rBytes, sBytes) + + # FWIW the ecdsa spec states that r and s must be | 1, N - 1 | + if signature.r.int() < 1: + raise Exception("signature r is less than one") + if signature.s.int() < 1: + raise Exception("signature s is less than one") + if signature.r.int() >= Curve.N: + raise Exception("signature r is >= curve.N") + if signature.s.int() >= Curve.N: + raise Exception("signature s is >= curve.N") + + return signature + class ScriptTokenizer: """ @@ -484,6 +635,43 @@ def canonicalizeInt(val): return b +def scriptNumBytes(n): + """ + scriptNumBytes returns a minimal encoding for a signed integer as bytes. + Based on dcrd/txscript (scriptNum).Bytes. + + Args: + n (int): The integer to encode. + + Returns: + ByteArray: The encoded bytes. + """ + if n == 0: + return ByteArray() + + isNegative = n < 0 + if isNegative: + n = -n + + result = ByteArray(length=9) + i = 0 + while n > 0: + result[i] = n & 0xFF + n = n >> 8 + i += 1 + + if result[i - 1] & 0x80 != 0: + extraByte = 0x00 + if isNegative: + extraByte = 0x80 + result[i] = extraByte + i += 1 + elif isNegative: + result[i - 1] |= 0x80 + + return result[:i] + + def hashToInt(h): """ hashToInt converts a hash value to an integer. There is some disagreement @@ -1209,6 +1397,21 @@ def payToStakeSHScript(addr, stakeCode): return script +def multiSigScript(addrs, nRequired): + if len(addrs) < nRequired: + raise Exception( + "unable to generate multisig script with {} required signatures when there are only {} public keys available".format( + nRequired, len(addrs) + ) + ) + script = ByteArray(addInt(nRequired)) + for addr in addrs: + script += addData(addr.scriptAddress()) + script += addInt(len(addrs)) + script += opcode.OP_CHECKMULTISIG + return script + + def payToSStx(addr): """ payToSStx creates a new script to pay a transaction output to a script hash or @@ -1237,6 +1440,50 @@ def payToSStx(addr): return payToStakeSHScript(addr, opcode.OP_SSTX) +def payToSSRtxPKHDirect(pkh): + """ + payToSSRtxPKHDirect creates a new script to pay a transaction output to a + public key hash, but tags the output with OP_SSRTX. For use in constructing + valid SSRtx. Unlike payToSSRtx, this function directly uses the HASH160 + pubkeyhash (instead of an address). + + Args: + sh (byte-like): raw script. + + Returns: + byte-like: script to pay a stake based public key hash. + """ + script = ByteArray(b"") + script += opcode.OP_SSRTX + script += opcode.OP_DUP + script += opcode.OP_HASH160 + script += addData(pkh) + script += opcode.OP_EQUALVERIFY + script += opcode.OP_CHECKSIG + return script + + +def payToSSRtxSHDirect(sh): + """ + payToSSRtxSHDirect creates a new script to pay a transaction output to a + script hash, but tags the output with OP_SSRTX. For use in constructing + valid SSRtx. Unlike payToSSRtx, this function directly uses the HASH160 + script hash (instead of an address). + + Args: + sh (byte-like): raw script. + + Returns: + byte-like: script to pay a stake based script hash. + """ + script = ByteArray(b"") + script += opcode.OP_SSRTX + script += opcode.OP_HASH160 + script += addData(sh) + script += opcode.OP_EQUAL + return script + + def generateSStxAddrPush(addr, amount, limits): """ generateSStxAddrPush generates an OP_RETURN push for SSGen payment addresses in @@ -1508,6 +1755,22 @@ def putVarInt(val): return reversed(ByteArray(0xFF, length=9)) | ByteArray(val, length=8).littleEndian() +def addInt(val): + """ + addInt pushes the passed integer to the end of the script. + """ + b = ByteArray(b"") + + # Fast path for small integers and OP_1NEGATE. + if val == 0: + b += opcode.OP_0 + return b + if val == -1 or (val >= 1 and val <= 16): + b += opcode.OP_1 - 1 + val + return b + return addData(scriptNumBytes(val)) + + def addData(data): dataLen = len(data) b = ByteArray(b"") @@ -2080,9 +2343,13 @@ def sign(chainParams, tx, idx, subScript, hashType, keysource, sigType): # return script, scriptClass, addresses, nrequired elif scriptClass == MultiSigTy: - raise Exception("spending multi-sig script not implemented") - # script = signMultiSig(tx, idx, subScript, hashType, addresses, nrequired, kdb) - # return script, scriptClass, addresses, nrequired + privKeys = [] + for addr in addresses: + privKeys.append(keysource.priv(addr)) + script = signMultiSig( + tx, idx, subScript, hashType, addresses, nrequired, privKeys + ) + return script, scriptClass, addresses, nrequired elif scriptClass == StakeSubmissionTy: return handleStakeOutSign( @@ -2142,6 +2409,42 @@ def sign(chainParams, tx, idx, subScript, hashType, keysource, sigType): raise Exception("can't sign unknown transactions") +def signMultiSig(tx, idx, subScript, hashType, addresses, nRequired, privKeys): + """ + signMultiSig signs as many of the outputs in the provided multisig script as + possible. It returns the generated script and a boolean if the script + fulfills the contract (i.e. nrequired signatures are provided). Since it is + arguably legal to not be able to sign any of the outputs, no error is + returned. + + Args: + tx (object): the ticket purchase MsgTx. + idx (int): the output index that contains the multisig. + subScript (byte-like): the multisig script. + hashType (int): the type of hash needed + addresses (list(object)): the addresses that make up the multisig. + nRequired (int): the number of signatures required to fulfill the pkScript. + privKeys (list(byte-like)): the private keys for addresses. + + Returns: + byte-like: the signed multisig script. + """ + + # No need to add dummy in Decred. + signed = 0 + script = ByteArray(b"") + for idx in range(len(addresses)): + + sig = rawTxInSignature(tx, idx, subScript, hashType, privKeys[idx].key) + + script += addData(sig) + signed += 1 + if signed == nRequired: + break + + return script + + def handleStakeOutSign( tx, idx, subScript, hashType, keysource, addresses, scriptClass, subClass, nrequired ): @@ -2236,9 +2539,9 @@ def mergeScripts( finalScript += addData(script) return finalScript elif scriptClass == MultiSigTy: - raise Exception("multisig signing unimplemented") - # return mergeMultiSig(tx, idx, addresses, nRequired, pkScript, - # sigScript, prevScript) + return mergeMultiSig( + tx, idx, addresses, nRequired, pkScript, sigScript, prevScript + ) else: # It doesn't actually make sense to merge anything other than multiig # and scripthash (because it could contain multisig). Everything else @@ -2251,6 +2554,117 @@ def mergeScripts( return prevScript +def mergeMultiSig(tx, idx, addresses, nRequired, pkScript, sigScript, prevScript): + """ + mergeMultiSig combines the two signature scripts sigScript and prevScript + that both provide signatures for pkScript in output idx of tx. addresses + and nRequired should be the results from extracting the addresses from + pkScript. Since this function is internal only we assume that the arguments + have come from other functions internally and thus are all consistent with + each other, behaviour is undefined if this contract is broken. + + NOTE: This function is only valid for version 0 scripts. Since the function + does not accept a script version, the results are undefined for other script + versions. + + Args: + tx (object): the ticket purchase MsgTx. + idx (int): the output index that contains the multisig. + addresses (object): the addresses that make up the multisig. + nRequired (int): the number of signatures required to fulfill the pkScript. + pkScript (byte-like): the multisig script. + sigScript (byte-like): the mulitsig script's signature. + prevScript (byte-like): the output's previous signature script. + + Returns: + byte-like: the merged signature scripts. + """ + + # Nothing to merge if either the new or previous signature scripts are + # empty. + if not sigScript or len(sigScript) == 0: + return prevScript + + if not prevScript or len(prevScript) == 0: + return sigScript + + # Convenience function to avoid duplication. + possibleSigs = [] + + def extractSigs(script): + scriptVersion = 0 + tokenizer = ScriptTokenizer(scriptVersion, script) + while tokenizer.next(): + data = tokenizer.data() + if len(data) != 0: + possibleSigs.append(data) + if tokenizer.err is not None: + raise Exception("mergeMultisig: extractSigs: {}".format(tokenizer.err)) + + # Attempt to extract signatures from the two scripts. Return the other + # script that is intended to be merged in the case signature extraction + # fails for some reason. + if not extractSigs(sigScript): + return prevScript + + if not extractSigs(prevScript): + return sigScript + + # Now we need to match the signatures to pubkeys, the only real way to + # do that is to try to verify them all and match it to the pubkey + # that verifies it. we then can go through the addresses in order + # to build our script. Anything that doesn't parse or doesn't verify we + # throw away. + addrToSig = {} + for sig in possibleSigs: + + # can't have a valid signature that doesn't at least have a + # hashtype, in practise it is even longer than this. but + # that'll be checked next. + if len(sig) < 1: + continue + tSig = sig[:-1] + hashType = sig[-1] + + pSig = Signature.parse(tSig, True) + if not pSig: + continue + + # We have to do this each round since hash types may vary + # between signatures and so the hash will vary. We can, + # however, assume no sigs etc are in the script since that + # would make the transaction nonstandard and thus not + # MultiSigTy, so we just need to hash the full thing. + hash = calcSignatureHash(pkScript, hashType, tx, idx, None) + + for addr in addresses: + # All multisig addresses should be pubkey addresses + # it is an error to call this internal function with + # bad input. + + # If it matches we put it in the map. We only + # can take one signature per public key so if we + # already have one, we can throw this away. + if verifySig(addr.pubkey, hash, pSig.r.int(), pSig.s.int()): + addrToSig[addr.string()] = sig + + script = ByteArray(b"") + doneSigs = 0 + # This assumes that addresses are in the same order as in the script. + for addr in addresses: + if addr.string() in addrToSig: + script += addData(addrToSig[addr.string()]) + doneSigs += 1 + if doneSigs == nRequired: + break + + # padding for missing ones + for i in range(nRequired - doneSigs): + script += opcode.OP_0 + + return script + + def signTxOutput( chainParams, tx, idx, pkScript, hashType, keysource, previousScript, sigType ): @@ -2816,3 +3230,198 @@ def makeTicket( checkSStx(mtx) return mtx + + +def sstxStakeOutputInfo(outs): + """ + sstxStakeOutputInfo takes a list of msgtx.txOut as input and scans through + its outputs, returning the pubkeyhashs and amounts for any NullDataTy's + (future commitments to stake generation rewards). + + Args: + outs (list(object)): an SStx MsgTx outputs + + Returns: + list(bool): is pay-to-script-hash. + list(byte-like): the output addresses. + list(int): the subsidy amounts. + list(int): the change amounts. + list(list(bool)): the spend rules. + list(list(int)): the spend limits. + """ + isP2SH = [] + addresses = [] + amounts = [] + changeAmounts = [] + allSpendRules = [] + allSpendLimits = [] + + # Cycle through the inputs and pull the proportional amounts + # and commit to PKHs/SHs. + for idx in range(len(outs)): + # We only care about the outputs where we get proportional + # amounts and the PKHs/SHs to send rewards to, which is all + # the odd numbered output indexes. + if (idx > 0) and (idx % 2 != 0): + # The MSB (sign), not used ever normally, encodes whether + # or not it is a P2PKH or P2SH for the input. + amtEncoded = outs[idx].pkScript[22:30] + # MSB set? + isP2SH.append(not (amtEncoded[7] & (1 << 7) == 0)) + # Clear bit + amtEncoded[7] &= 127 + + addresses.append(outs[idx].pkScript[2:22]) + # amounts[idx/2] = int64(binary.LittleEndian.Uint64(amtEncoded)) + amounts.append(ByteArray(amtEncoded, length=8).littleEndian().int()) + + # Get flags and restrictions for the outputs to be + # made in either a vote or revocation. + spendRules = [] + spendLimits = [] + + # This bitflag is true/false. + feeLimitUint16 = ( + ByteArray(outs[idx].pkScript[30:32], length=4).littleEndian().int() + ) + spendRules.append( + (feeLimitUint16 & SStxVoteFractionFlag) == SStxVoteFractionFlag + ) + spendRules.append( + (feeLimitUint16 & SStxRevFractionFlag) == SStxRevFractionFlag + ) + allSpendRules.append(spendRules) + + # This is the fraction to use out of 64. + spendLimits.append(feeLimitUint16 & SStxVoteReturnFractionMask) + spendLimits.append(feeLimitUint16 & SStxRevReturnFractionMask) + spendLimits[1] >>= 8 + allSpendLimits.append(spendLimits) + + # Here we only care about the change amounts, so scan + # the change outputs (even indices) and save their + # amounts. + if (idx > 0) and (idx % 2 == 0): + changeAmounts.append(outs[idx].value) + + return isP2SH, addresses, amounts, changeAmounts, allSpendRules, allSpendLimits + + +def calculateRewards(amounts, amountTicket, subsidy): + """ + calculateRewards takes a list of SStx adjusted output amounts, the amount used + to purchase that ticket, and the reward for an SSGen tx and subsequently + generates what the outputs should be in the SSGen tx. If used for calculating + the outputs for an SSRtx, pass 0 for subsidy. + + Args: + amounts list (int): output amounts. + amountTicket (int): amount used to purchase ticket. + subsidy (int): amount to pay. + + Returns: + list(int): list of SStx adjusted output amounts. + """ + outputsAmounts = [] + + # SSGen handling + amountWithStakebase = amountTicket + subsidy + + # Get the sum of the amounts contributed between both fees + # and contributions to the ticket. + totalContrib = 0 + for amount in amounts: + totalContrib += amount + + # Now we want to get the adjusted amounts including the reward. + # The algorithm is like this: + # 1 foreach amount + # 2 amount *= 2^32 + # 3 amount /= amountTicket + # 4 amount *= amountWithStakebase + # 5 amount /= 2^32 + + for amount in amounts: + # mul amountWithStakebase + amount *= amountWithStakebase + + # mul 2^32 + amount <<= 32 + + # div totalContrib + amount //= totalContrib + + # div 2^32 + amount >>= 32 + + # make int64 + outputsAmounts.append(amount) + + return outputsAmounts + + +def makeRevocation(ticketPurchase, feePerKB): + """ + makeRevocation creates an unsigned revocation transaction that + revokes a missed or expired ticket. Revocations must carry a relay fee and + this function can error if the revocation contains no suitable output to + decrease the estimated relay fee from. + + Args: + ticketPurchase (object): the ticket to revoke's MsgTx. + feePerKB (int): the miner's fee per kb. + + Returns: + object: the unsigned revocation MsgTx or None in case of error. + """ + # Parse the ticket purchase transaction to determine the required output + # destinations for vote rewards or revocations. + ticketPayKinds, ticketHash160s, ticketValues, _, _, _ = sstxStakeOutputInfo( + ticketPurchase.txOut + ) + + # Calculate the output values for the revocation. Revocations do not + # contain any subsidy. + revocationValues = calculateRewards(ticketValues, ticketPurchase.txOut[0].value, 0) + + # Begin constructing the revocation transaction. + revocation = msgtx.MsgTx.new() + + # Revocations reference the ticket purchase with the first (and only) + # input. + ticketOutPoint = msgtx.OutPoint(ticketPurchase.hash(), 0, msgtx.TxTreeStake) + ticketInput = msgtx.TxIn( + previousOutPoint=ticketOutPoint, + valueIn=ticketPurchase.txOut[ticketOutPoint.index].value, + ) + revocation.addTxIn(ticketInput) + scriptSizes = [RedeemP2SHSigScriptSize] + + # All remaining outputs pay to the output destinations and amounts tagged + # by the ticket purchase. + for i in range(len(ticketHash160s)): + scriptFn = payToSSRtxPKHDirect + # P2SH + if ticketPayKinds[i]: + scriptFn = payToSSRtxSHDirect + script = scriptFn(ticketHash160s[i]) + revocation.addTxOut(msgtx.TxOut(revocationValues[i], script)) + + # Revocations must pay a fee but do so by decreasing one of the output + # values instead of increasing the input value and using a change output. + # Calculate the estimated signed serialize size. + sizeEstimate = estimateSerializeSize(scriptSizes, revocation.txOut, 0) + feeEstimate = calcMinRequiredTxRelayFee(feePerKB, sizeEstimate) + + # Reduce the output value of one of the outputs to accommodate for the relay + # fee. To avoid creating dust outputs, a suitable output value is reduced + # by the fee estimate only if it is large enough to not create dust. This + # code does not currently handle reducing the output values of multiple + # commitment outputs to accommodate for the fee. + for output in revocation.txOut: + if output.value > feeEstimate: + amount = output.value - feeEstimate + if not isDustAmount(amount, len(output.pkScript), feePerKB): + output.value = amount + return revocation + raise Exception("missing suitable revocation output to pay relay fee") diff --git a/tests/integration/pydecred/test_dcrdata.py b/tests/integration/pydecred/test_dcrdata.py index 831f9389..4e3a0c74 100644 --- a/tests/integration/pydecred/test_dcrdata.py +++ b/tests/integration/pydecred/test_dcrdata.py @@ -8,8 +8,9 @@ from tempfile import TemporaryDirectory import time -from tinydecred.pydecred import mainnet, testnet, txscript, dcrdata +from tinydecred.pydecred import mainnet, testnet, txscript, dcrdata, account from tinydecred.pydecred.wire import msgtx +from tinydecred.crypto.bytearray import ByteArray from tinydecred.crypto import crypto @@ -145,3 +146,50 @@ class request: ticket, spent, newUTXOs = blockchain.purchaseTickets( KeySource(), utxosource, request() ) + + def test_revoke_ticket(self): + with TemporaryDirectory() as tempDir: + blockchain = dcrdata.DcrdataBlockchain( + os.path.join(tempDir, "db.db"), testnet, "https://testnet.dcrdata.org" + ) + blockchain.connect() + + def broadcast(txHex): + print("test skipping broadcast of transaction: %s" % txHex) + return True + + blockchain.broadcast = broadcast + + class test: + def __init__( + self, ticket="", privKey="", redeemScript="", revocation="", + ): + self.ticket = ticket + self.privKey = privKey + self.redeemScript = redeemScript + self.revocation = revocation + + tests = [ + test( + "010000000210fd1f5623e2469d9bb390ad21b12f6710f5d6be0e130df074cfd8614d0c4e050400000000ffffffff10fd1f5623e2469d9bb390ad21b12f6710f5d6be0e130df074cfd8614d0c4e050500000000ffffffff05508f4a7401000000000018baa91438a8a93737e62e806f49d1465518a02f110d57fe8700000000000000000000206a1e1aee120db13f4e4f785aec3da97b48963de58ebd37570200000000000058000000000000000000001abd76a914000000000000000000000000000000000000000088ac00000000000000000000206a1eabc7372997a530b43e8e17a4850602b28e0768dd454d4874010000000058000000000000000000001abd76a914000000000000000000000000000000000000000088ac000000000000000002375702000000000000000000000000006a47304402202b5f4a97abf78d95875de75b244f9b4c7f60bb40b01a91881331022a03a3bf32022047f951e0414e4f28518b5254974abeb93f77727bee7c6fbd5010f8bf375dd10f012102ec908402cb3ab128a9a68978fbb3b33f1c97d715afb8f76dbbe300619878f095454d48740100000000000000000000006a47304402200435095049ac7b3f3c47a43d92afc22db032b929357f2f216eb24e91cd0d2d2802203ca93654cc1f193f11ce0c7e74f9959edd984c15d54f99b0d227b1cdd99a4a3e012102ec908402cb3ab128a9a68978fbb3b33f1c97d715afb8f76dbbe300619878f095", + "d407f81cb789e65579590d5e50027431f1fdea21c2ebef12944b7842e71eaf", + "51210289a43bf822daf338bb07555476a967cc46545a58c513a0badc99861c81f985782103b1f62148c92802a47ce98a49d1f14f397adc759131f6f6a5c88ad9dfedd53f9b52ae", + "0100000001113d36ae0156c1f5a3071de1f7f10e9e4521a77266b1927f832babb9fe3d5cd90000000001ffffffff022c4d02000000000000001abc76a9141aee120db13f4e4f785aec3da97b48963de58ebd88ac193848740100000000001abc76a914abc7372997a530b43e8e17a4850602b28e0768dd88ac000000000000000001508f4a740100000000000000000000009148304502210099b8e13022e13d19229fff3a2b08ebf54f5cd4ea79c85618d04a57a735283e9102203e0fd5a66a168edff1bbcaeaa9d5b2d5e224cbd5d90a6c0099c73810d7493924014751210289a43bf822daf338bb07555476a967cc46545a58c513a0badc99861c81f985782103b1f62148c92802a47ce98a49d1f14f397adc759131f6f6a5c88ad9dfedd53f9b52ae", + ), + test( + "010000000228a301cae233e252143e6fff3ccd348e156bbcc8ea158cfb4a1fe19f78cad1e80a00000000ffffffff28a301cae233e252143e6fff3ccd348e156bbcc8ea158cfb4a1fe19f78cad1e80b00000000ffffffff05d00fb2fb00000000000018baa91438a8a93737e62e806f49d1465518a02f110d57fe8700000000000000000000206a1e1aee120db13f4e4f785aec3da97b48963de58ebdcf550200000000000058000000000000000000001abd76a914000000000000000000000000000000000000000088ac00000000000000000000206a1e949293b5f8acd5a0a871ce417cd5354d9715a2912dcfaffb000000000058000000000000000000001abd76a914000000000000000000000000000000000000000088ac000000000000000002cf5502000000000000000000000000006b483045022100a4072a6a09058cf1b2713b75e636bcdf418c9897aa18cd584cfcf0229bf6b7d102200cb94cfef85a31943ab1e0daba90578c3b465be26e4a94a19e1da5ec70586193012103ac3936e6c8d0fd9cefde6cc9552289c943a1eb4abe1601de8f8e46b8f7ed508d2dcfaffb0000000000000000000000006a47304402202fc0578332b69746109066cc7d7cb9b86f0a693639bbe4dc84c83f26ef0e3bad022010ad57133bcc77bb36600488679f5da6ab6740b4d743470bcd60f2ecda70aa7f012103ac3936e6c8d0fd9cefde6cc9552289c943a1eb4abe1601de8f8e46b8f7ed508d", + "d407f81cb789e65579590d5e50027431f1fdea21c2ebef12944b7842e71eaf", + "51210289a43bf822daf338bb07555476a967cc46545a58c513a0badc99861c81f985782103b1f62148c92802a47ce98a49d1f14f397adc759131f6f6a5c88ad9dfedd53f9b52ae", + "0100000001d033e1ddf9c44a1402d8dc8d6cfcee634a459537a078359a3c51153b7ba67b220000000001ffffffff02c44b02000000000000001abc76a9141aee120db13f4e4f785aec3da97b48963de58ebd88ac01baaffb0000000000001abc76a914949293b5f8acd5a0a871ce417cd5354d9715a29188ac000000000000000001d00fb2fb00000000000000000000000091483045022100f83aa623b21d302cdc65b6b227fe53f3796379031dd1fee9bc398680f846221d022062282396b391ba38612afea9a648b7db7ef54b0d4b06bddae7783bb25cf6ba1f014751210289a43bf822daf338bb07555476a967cc46545a58c513a0badc99861c81f985782103b1f62148c92802a47ce98a49d1f14f397adc759131f6f6a5c88ad9dfedd53f9b52ae", + ), + ] + + for test in tests: + ticket = msgtx.MsgTx.deserialize(ByteArray(test.ticket)) + keysource = account.KeySource( + priv=lambda _: crypto.privKeyFromBytes(ByteArray(test.privKey)), + internal=lambda: "", + ) + redeemScript = ByteArray(test.redeemScript) + revocation = blockchain.revokeTicket(ticket, keysource, redeemScript) + self.assertEqual(test.revocation, revocation.txHex()) diff --git a/tests/unit/pydecred/test_pydecred.py b/tests/unit/pydecred/test_pydecred.py index f9b4c840..baccde04 100644 --- a/tests/unit/pydecred/test_pydecred.py +++ b/tests/unit/pydecred/test_pydecred.py @@ -1128,6 +1128,293 @@ def test_script_tokenizer(self): % (test_name, tokenizerIdx, test_finalIdx), ) + def test_signature(self): + class test: + def __init__(self, name, sig, der, isValid): + self.name = name + self.sig = sig + self.der = der + self.isValid = isValid + + # fmt: off + tests = [ + # signatures from bitcoin blockchain tx + # 0437cd7f8525ceed2324359c2d0ba26006d92d85 + test( + "valid signature.", + [0x30, 0x44, 0x02, 0x20, 0x4e, 0x45, 0xe1, 0x69, + 0x32, 0xb8, 0xaf, 0x51, 0x49, 0x61, 0xa1, 0xd3, 0xa1, + 0xa2, 0x5f, 0xdf, 0x3f, 0x4f, 0x77, 0x32, 0xe9, 0xd6, + 0x24, 0xc6, 0xc6, 0x15, 0x48, 0xab, 0x5f, 0xb8, 0xcd, + 0x41, 0x02, 0x20, 0x18, 0x15, 0x22, 0xec, 0x8e, 0xca, + 0x07, 0xde, 0x48, 0x60, 0xa4, 0xac, 0xdd, 0x12, 0x90, + 0x9d, 0x83, 0x1c, 0xc5, 0x6c, 0xbb, 0xac, 0x46, 0x22, + 0x08, 0x22, 0x21, 0xa8, 0x76, 0x8d, 0x1d, 0x09], + True, + True, + ), + test( + "empty.", + [], + "", + False, + ), + test( + "bad magic.", + [0x31, 0x44, 0x02, 0x20, 0x4e, 0x45, 0xe1, 0x69, + 0x32, 0xb8, 0xaf, 0x51, 0x49, 0x61, 0xa1, 0xd3, 0xa1, + 0xa2, 0x5f, 0xdf, 0x3f, 0x4f, 0x77, 0x32, 0xe9, 0xd6, + 0x24, 0xc6, 0xc6, 0x15, 0x48, 0xab, 0x5f, 0xb8, 0xcd, + 0x41, 0x02, 0x20, 0x18, 0x15, 0x22, 0xec, 0x8e, 0xca, + 0x07, 0xde, 0x48, 0x60, 0xa4, 0xac, 0xdd, 0x12, 0x90, + 0x9d, 0x83, 0x1c, 0xc5, 0x6c, 0xbb, 0xac, 0x46, 0x22, + 0x08, 0x22, 0x21, 0xa8, 0x76, 0x8d, 0x1d, 0x09], + True, + False, + ), + test( + "bad 1st int marker magic.", + [0x30, 0x44, 0x03, 0x20, 0x4e, 0x45, 0xe1, 0x69, + 0x32, 0xb8, 0xaf, 0x51, 0x49, 0x61, 0xa1, 0xd3, 0xa1, + 0xa2, 0x5f, 0xdf, 0x3f, 0x4f, 0x77, 0x32, 0xe9, 0xd6, + 0x24, 0xc6, 0xc6, 0x15, 0x48, 0xab, 0x5f, 0xb8, 0xcd, + 0x41, 0x02, 0x20, 0x18, 0x15, 0x22, 0xec, 0x8e, 0xca, + 0x07, 0xde, 0x48, 0x60, 0xa4, 0xac, 0xdd, 0x12, 0x90, + 0x9d, 0x83, 0x1c, 0xc5, 0x6c, 0xbb, 0xac, 0x46, 0x22, + 0x08, 0x22, 0x21, 0xa8, 0x76, 0x8d, 0x1d, 0x09], + True, + False, + ), + test( + "bad 2nd int marker.", + [0x30, 0x44, 0x02, 0x20, 0x4e, 0x45, 0xe1, 0x69, + 0x32, 0xb8, 0xaf, 0x51, 0x49, 0x61, 0xa1, 0xd3, 0xa1, + 0xa2, 0x5f, 0xdf, 0x3f, 0x4f, 0x77, 0x32, 0xe9, 0xd6, + 0x24, 0xc6, 0xc6, 0x15, 0x48, 0xab, 0x5f, 0xb8, 0xcd, + 0x41, 0x03, 0x20, 0x18, 0x15, 0x22, 0xec, 0x8e, 0xca, + 0x07, 0xde, 0x48, 0x60, 0xa4, 0xac, 0xdd, 0x12, 0x90, + 0x9d, 0x83, 0x1c, 0xc5, 0x6c, 0xbb, 0xac, 0x46, 0x22, + 0x08, 0x22, 0x21, 0xa8, 0x76, 0x8d, 0x1d, 0x09], + True, + False, + ), + test( + "short len", + [0x30, 0x43, 0x02, 0x20, 0x4e, 0x45, 0xe1, 0x69, + 0x32, 0xb8, 0xaf, 0x51, 0x49, 0x61, 0xa1, 0xd3, 0xa1, + 0xa2, 0x5f, 0xdf, 0x3f, 0x4f, 0x77, 0x32, 0xe9, 0xd6, + 0x24, 0xc6, 0xc6, 0x15, 0x48, 0xab, 0x5f, 0xb8, 0xcd, + 0x41, 0x02, 0x20, 0x18, 0x15, 0x22, 0xec, 0x8e, 0xca, + 0x07, 0xde, 0x48, 0x60, 0xa4, 0xac, 0xdd, 0x12, 0x90, + 0x9d, 0x83, 0x1c, 0xc5, 0x6c, 0xbb, 0xac, 0x46, 0x22, + 0x08, 0x22, 0x21, 0xa8, 0x76, 0x8d, 0x1d, 0x09], + True, + False, + ), + test( + "long len", + [0x30, 0x45, 0x02, 0x20, 0x4e, 0x45, 0xe1, 0x69, + 0x32, 0xb8, 0xaf, 0x51, 0x49, 0x61, 0xa1, 0xd3, 0xa1, + 0xa2, 0x5f, 0xdf, 0x3f, 0x4f, 0x77, 0x32, 0xe9, 0xd6, + 0x24, 0xc6, 0xc6, 0x15, 0x48, 0xab, 0x5f, 0xb8, 0xcd, + 0x41, 0x02, 0x20, 0x18, 0x15, 0x22, 0xec, 0x8e, 0xca, + 0x07, 0xde, 0x48, 0x60, 0xa4, 0xac, 0xdd, 0x12, 0x90, + 0x9d, 0x83, 0x1c, 0xc5, 0x6c, 0xbb, 0xac, 0x46, 0x22, + 0x08, 0x22, 0x21, 0xa8, 0x76, 0x8d, 0x1d, 0x09], + True, + False, + ), + test( + "long X", + [0x30, 0x44, 0x02, 0x42, 0x4e, 0x45, 0xe1, 0x69, + 0x32, 0xb8, 0xaf, 0x51, 0x49, 0x61, 0xa1, 0xd3, 0xa1, + 0xa2, 0x5f, 0xdf, 0x3f, 0x4f, 0x77, 0x32, 0xe9, 0xd6, + 0x24, 0xc6, 0xc6, 0x15, 0x48, 0xab, 0x5f, 0xb8, 0xcd, + 0x41, 0x02, 0x20, 0x18, 0x15, 0x22, 0xec, 0x8e, 0xca, + 0x07, 0xde, 0x48, 0x60, 0xa4, 0xac, 0xdd, 0x12, 0x90, + 0x9d, 0x83, 0x1c, 0xc5, 0x6c, 0xbb, 0xac, 0x46, 0x22, + 0x08, 0x22, 0x21, 0xa8, 0x76, 0x8d, 0x1d, 0x09], + True, + False, + ), + test( + "long Y", + [0x30, 0x44, 0x02, 0x20, 0x4e, 0x45, 0xe1, 0x69, + 0x32, 0xb8, 0xaf, 0x51, 0x49, 0x61, 0xa1, 0xd3, 0xa1, + 0xa2, 0x5f, 0xdf, 0x3f, 0x4f, 0x77, 0x32, 0xe9, 0xd6, + 0x24, 0xc6, 0xc6, 0x15, 0x48, 0xab, 0x5f, 0xb8, 0xcd, + 0x41, 0x02, 0x21, 0x18, 0x15, 0x22, 0xec, 0x8e, 0xca, + 0x07, 0xde, 0x48, 0x60, 0xa4, 0xac, 0xdd, 0x12, 0x90, + 0x9d, 0x83, 0x1c, 0xc5, 0x6c, 0xbb, 0xac, 0x46, 0x22, + 0x08, 0x22, 0x21, 0xa8, 0x76, 0x8d, 0x1d, 0x09], + True, + False, + ), + test( + "short Y", + [0x30, 0x44, 0x02, 0x20, 0x4e, 0x45, 0xe1, 0x69, + 0x32, 0xb8, 0xaf, 0x51, 0x49, 0x61, 0xa1, 0xd3, 0xa1, + 0xa2, 0x5f, 0xdf, 0x3f, 0x4f, 0x77, 0x32, 0xe9, 0xd6, + 0x24, 0xc6, 0xc6, 0x15, 0x48, 0xab, 0x5f, 0xb8, 0xcd, + 0x41, 0x02, 0x19, 0x18, 0x15, 0x22, 0xec, 0x8e, 0xca, + 0x07, 0xde, 0x48, 0x60, 0xa4, 0xac, 0xdd, 0x12, 0x90, + 0x9d, 0x83, 0x1c, 0xc5, 0x6c, 0xbb, 0xac, 0x46, 0x22, + 0x08, 0x22, 0x21, 0xa8, 0x76, 0x8d, 0x1d, 0x09], + True, + False, + ), + test( + "trailing crap.", + [0x30, 0x44, 0x02, 0x20, 0x4e, 0x45, 0xe1, 0x69, + 0x32, 0xb8, 0xaf, 0x51, 0x49, 0x61, 0xa1, 0xd3, 0xa1, + 0xa2, 0x5f, 0xdf, 0x3f, 0x4f, 0x77, 0x32, 0xe9, 0xd6, + 0x24, 0xc6, 0xc6, 0x15, 0x48, 0xab, 0x5f, 0xb8, 0xcd, + 0x41, 0x02, 0x20, 0x18, 0x15, 0x22, 0xec, 0x8e, 0xca, + 0x07, 0xde, 0x48, 0x60, 0xa4, 0xac, 0xdd, 0x12, 0x90, + 0x9d, 0x83, 0x1c, 0xc5, 0x6c, 0xbb, 0xac, 0x46, 0x22, + 0x08, 0x22, 0x21, 0xa8, 0x76, 0x8d, 0x1d, 0x09, 0x01], + True, + # This test is now passing (used to be failing) because there + # are signatures in the blockchain that have trailing zero + # bytes before the hashtype. So ParseSignature was fixed to + # permit buffers with trailing nonsense after the actual + # signature. + True, + ), + test( + "X == N ", + [0x30, 0x44, 0x02, 0x20, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFE, 0xBA, 0xAE, 0xDC, 0xE6, 0xAF, 0x48, + 0xA0, 0x3B, 0xBF, 0xD2, 0x5E, 0x8C, 0xD0, 0x36, 0x41, + 0x41, 0x02, 0x20, 0x18, 0x15, 0x22, 0xec, 0x8e, 0xca, + 0x07, 0xde, 0x48, 0x60, 0xa4, 0xac, 0xdd, 0x12, 0x90, + 0x9d, 0x83, 0x1c, 0xc5, 0x6c, 0xbb, 0xac, 0x46, 0x22, + 0x08, 0x22, 0x21, 0xa8, 0x76, 0x8d, 0x1d, 0x09], + True, + False, + ), + test( + "X == N ", + [0x30, 0x44, 0x02, 0x20, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFE, 0xBA, 0xAE, 0xDC, 0xE6, 0xAF, 0x48, + 0xA0, 0x3B, 0xBF, 0xD2, 0x5E, 0x8C, 0xD0, 0x36, 0x41, + 0x42, 0x02, 0x20, 0x18, 0x15, 0x22, 0xec, 0x8e, 0xca, + 0x07, 0xde, 0x48, 0x60, 0xa4, 0xac, 0xdd, 0x12, 0x90, + 0x9d, 0x83, 0x1c, 0xc5, 0x6c, 0xbb, 0xac, 0x46, 0x22, + 0x08, 0x22, 0x21, 0xa8, 0x76, 0x8d, 0x1d, 0x09], + False, + False, + ), + test( + "Y == N", + [0x30, 0x44, 0x02, 0x20, 0x4e, 0x45, 0xe1, 0x69, + 0x32, 0xb8, 0xaf, 0x51, 0x49, 0x61, 0xa1, 0xd3, 0xa1, + 0xa2, 0x5f, 0xdf, 0x3f, 0x4f, 0x77, 0x32, 0xe9, 0xd6, + 0x24, 0xc6, 0xc6, 0x15, 0x48, 0xab, 0x5f, 0xb8, 0xcd, + 0x41, 0x02, 0x20, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFE, 0xBA, 0xAE, 0xDC, 0xE6, 0xAF, 0x48, 0xA0, 0x3B, + 0xBF, 0xD2, 0x5E, 0x8C, 0xD0, 0x36, 0x41, 0x41], + True, + False, + ), + test( + "Y > N", + [0x30, 0x44, 0x02, 0x20, 0x4e, 0x45, 0xe1, 0x69, + 0x32, 0xb8, 0xaf, 0x51, 0x49, 0x61, 0xa1, 0xd3, 0xa1, + 0xa2, 0x5f, 0xdf, 0x3f, 0x4f, 0x77, 0x32, 0xe9, 0xd6, + 0x24, 0xc6, 0xc6, 0x15, 0x48, 0xab, 0x5f, 0xb8, 0xcd, + 0x41, 0x02, 0x20, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFE, 0xBA, 0xAE, 0xDC, 0xE6, 0xAF, 0x48, 0xA0, 0x3B, + 0xBF, 0xD2, 0x5E, 0x8C, 0xD0, 0x36, 0x41, 0x42], + False, + False, + ), + test( + "0 len X.", + [0x30, 0x24, 0x02, 0x00, 0x02, 0x20, 0x18, 0x15, + 0x22, 0xec, 0x8e, 0xca, 0x07, 0xde, 0x48, 0x60, 0xa4, + 0xac, 0xdd, 0x12, 0x90, 0x9d, 0x83, 0x1c, 0xc5, 0x6c, + 0xbb, 0xac, 0x46, 0x22, 0x08, 0x22, 0x21, 0xa8, 0x76, + 0x8d, 0x1d, 0x09], + True, + False, + ), + test( + "0 len Y.", + [0x30, 0x24, 0x02, 0x20, 0x4e, 0x45, 0xe1, 0x69, + 0x32, 0xb8, 0xaf, 0x51, 0x49, 0x61, 0xa1, 0xd3, 0xa1, + 0xa2, 0x5f, 0xdf, 0x3f, 0x4f, 0x77, 0x32, 0xe9, 0xd6, + 0x24, 0xc6, 0xc6, 0x15, 0x48, 0xab, 0x5f, 0xb8, 0xcd, + 0x41, 0x02, 0x00], + True, + False, + ), + test( + "extra R padding.", + [0x30, 0x45, 0x02, 0x21, 0x00, 0x4e, 0x45, 0xe1, 0x69, + 0x32, 0xb8, 0xaf, 0x51, 0x49, 0x61, 0xa1, 0xd3, 0xa1, + 0xa2, 0x5f, 0xdf, 0x3f, 0x4f, 0x77, 0x32, 0xe9, 0xd6, + 0x24, 0xc6, 0xc6, 0x15, 0x48, 0xab, 0x5f, 0xb8, 0xcd, + 0x41, 0x02, 0x20, 0x18, 0x15, 0x22, 0xec, 0x8e, 0xca, + 0x07, 0xde, 0x48, 0x60, 0xa4, 0xac, 0xdd, 0x12, 0x90, + 0x9d, 0x83, 0x1c, 0xc5, 0x6c, 0xbb, 0xac, 0x46, 0x22, + 0x08, 0x22, 0x21, 0xa8, 0x76, 0x8d, 0x1d, 0x09], + True, + False, + ), + test( + "extra S padding.", + [0x30, 0x45, 0x02, 0x20, 0x4e, 0x45, 0xe1, 0x69, + 0x32, 0xb8, 0xaf, 0x51, 0x49, 0x61, 0xa1, 0xd3, 0xa1, + 0xa2, 0x5f, 0xdf, 0x3f, 0x4f, 0x77, 0x32, 0xe9, 0xd6, + 0x24, 0xc6, 0xc6, 0x15, 0x48, 0xab, 0x5f, 0xb8, 0xcd, + 0x41, 0x02, 0x21, 0x00, 0x18, 0x15, 0x22, 0xec, 0x8e, 0xca, + 0x07, 0xde, 0x48, 0x60, 0xa4, 0xac, 0xdd, 0x12, 0x90, + 0x9d, 0x83, 0x1c, 0xc5, 0x6c, 0xbb, 0xac, 0x46, 0x22, + 0x08, 0x22, 0x21, 0xa8, 0x76, 0x8d, 0x1d, 0x09], + True, + False, + ), + # Standard checks (in BER format, without checking for 'canonical' DER + # signatures) don't test for negative numbers here because there isn't + # a way that is the same between openssl and go that will mark a number + # as negative. The Go ASN.1 parser marks numbers as negative when + # openssl does not (it doesn't handle negative numbers that I can tell + # at all. When not parsing DER signatures, which is done by by bitcoind + # when accepting transactions into its mempool, we otherwise only check + # for the coordinates being zero. + test( + "X == 0", + [0x30, 0x25, 0x02, 0x01, 0x00, 0x02, 0x20, 0x18, + 0x15, 0x22, 0xec, 0x8e, 0xca, 0x07, 0xde, 0x48, 0x60, + 0xa4, 0xac, 0xdd, 0x12, 0x90, 0x9d, 0x83, 0x1c, 0xc5, + 0x6c, 0xbb, 0xac, 0x46, 0x22, 0x08, 0x22, 0x21, 0xa8, + 0x76, 0x8d, 0x1d, 0x09], + False, + False, + ), + test( + "Y == 0.", + [0x30, 0x25, 0x02, 0x20, 0x4e, 0x45, 0xe1, 0x69, + 0x32, 0xb8, 0xaf, 0x51, 0x49, 0x61, 0xa1, 0xd3, 0xa1, + 0xa2, 0x5f, 0xdf, 0x3f, 0x4f, 0x77, 0x32, 0xe9, 0xd6, + 0x24, 0xc6, 0xc6, 0x15, 0x48, 0xab, 0x5f, 0xb8, 0xcd, + 0x41, 0x02, 0x01, 0x00], + False, + False, + ), + ] + # fmt: on + for test in tests: + try: + txscript.Signature.parse(ByteArray(test.sig), test.der) + except Exception: + assert test.isValid is False + def test_sign_tx(self): """ Based on dcrd TestSignTxOutput. @@ -1248,7 +1535,7 @@ def test_sign_tx(self): # ), # ) - # Pay to Pubkey Hash (uncompressed) + # Pay to Pubkey Hash (compressed) testingParams = mainnet for hashType in hashTypes: for suite in signatureSuites: @@ -1297,7 +1584,215 @@ def priv(addr): ByteArray(sigStr), msg="%d:%d:%d" % (hashType, idx, suite), ) - return + + # Pay to Pubkey Hash for a ticket (SStx) (compressed) + # For compressed keys + tests = ( + ( + "b78a743c0c6557f24a51192b82925942ebade0be86efd7dad58b9fa358d3857c", + "4730440220411b0a068d5b1c5fd6ec98a0e3f17ce632a863a9d57876c0bde2647a8dcd26c602204f05f109f0f185cc79a43168411075eb58fd350cc135f4872b0b8c81015e21c3012102e11d2c0e415343435294079ac0774a21c8e6b1e6fd9b671cb08af43a397f3df1", + ), + ( + "a00616c21b117ba621d4c72faf30d30cd665416bdc3c24e549de2348ac68cfb8", + "473044022050a359daf7db3db11e95ceb8494173f8ca168b32ccc6cc57dcad5f78564678af02200c09e2c7c72736ef9835f05eb0c6eb72fdd2e1e98cdaf7af7f2d9523ed5f410501210224397bd81b0e80ec1bbfe104fb251b57eb0adcf044c3eec05d913e2e8e04396b", + ), + ( + "8902ea1f64c6fb7aa40dfbe798f5dc53b466a3fc01534e867581936a8ecbff5b", + "4730440220257fe3c52ce408561aec4446c30bca6d6fad98ba554917c4e7714a89badbfdbf02201aa569c5e28d728dd20ce32656915729ebc6679527bfe2401ea3723791e04538012103255f71eab9eb2a7e3f822569484448acbe2880d61b4db61020f73fd54cbe370d", + ), + ) + + testingParams = mainnet + for hashType in hashTypes: + for suite in signatureSuites: + for idx in range(len(tx.txIn)): + # var keyDB, pkBytes []byte + # var key chainec.PrivateKey + # var pk chainec.PublicKey + kStr, sigStr = tests[idx] + + if suite == crypto.STEcdsaSecp256k1: + # k = Curve.generateKey(rand.Reader) + k = ByteArray(kStr) + privKey = crypto.privKeyFromBytes(k) + pkBytes = privKey.pub.serializeCompressed() + else: + raise Exception( + "test for signature suite %d not implemented" % suite + ) + + address = crypto.newAddressPubKeyHash( + crypto.hash160(pkBytes.bytes()), testingParams, suite + ) + + pkScript = txscript.payToSStx(address) + + class keysource: + @staticmethod + def priv(addr): + return privKey + + sigScript = txscript.signTxOutput( + testingParams, + tx, + idx, + pkScript, + hashType, + keysource, + None, + suite, + ) + + self.assertEqual( + sigScript, + ByteArray(sigStr), + msg="%d:%d:%d" % (hashType, idx, suite), + ) + + # Pay to Pubkey Hash for a ticket revocation (SSRtx) (compressed) + # For compressed keys + tests = ( + ( + "b78a743c0c6557f24a51192b82925942ebade0be86efd7dad58b9fa358d3857c", + "483045022100ad46b5bd365af6964562bfac90abad9d9cf30fdc53ae4011103c646df04a7d5f022076209ea5626cb9a3f16add11c361f6f66c7436eec8efe1688e43ac9f71a86b88012102e11d2c0e415343435294079ac0774a21c8e6b1e6fd9b671cb08af43a397f3df1", + ), + ( + "a00616c21b117ba621d4c72faf30d30cd665416bdc3c24e549de2348ac68cfb8", + "483045022100eeacc7f3fcba009f6ab319b2221e64d52d94d5009cfd037ef03c86dc1bcb2c990220212000f05d1a904d3d995b18b8b94bd0e84dc35aa308df5149094678f6cd40e501210224397bd81b0e80ec1bbfe104fb251b57eb0adcf044c3eec05d913e2e8e04396b", + ), + ( + "8902ea1f64c6fb7aa40dfbe798f5dc53b466a3fc01534e867581936a8ecbff5b", + "47304402200fa66dd2be65cd8c0e89bc299b99cadac36805af627432cbdc968c53b4c4f41b02200b117b145dfdb6ba7846b9b02c63d85d11bfc2188f58f083da6bb88220a9e517012103255f71eab9eb2a7e3f822569484448acbe2880d61b4db61020f73fd54cbe370d", + ), + ) + + testingParams = mainnet + for hashType in hashTypes: + for suite in signatureSuites: + for idx in range(len(tx.txIn)): + # var keyDB, pkBytes []byte + # var key chainec.PrivateKey + # var pk chainec.PublicKey + kStr, sigStr = tests[idx] + + if suite == crypto.STEcdsaSecp256k1: + # k = Curve.generateKey(rand.Reader) + k = ByteArray(kStr) + privKey = crypto.privKeyFromBytes(k) + pkBytes = privKey.pub.serializeCompressed() + else: + raise Exception( + "test for signature suite %d not implemented" % suite + ) + + address = crypto.newAddressPubKeyHash( + crypto.hash160(pkBytes.bytes()), testingParams, suite + ) + + pkScript = txscript.payToSSRtxPKHDirect( + txscript.decodeAddress( + address.string(), testingParams + ).scriptAddress() + ) + + class keysource: + @staticmethod + def priv(addr): + return privKey + + sigScript = txscript.signTxOutput( + testingParams, + tx, + idx, + pkScript, + hashType, + keysource, + None, + suite, + ) + + self.assertEqual( + sigScript, + ByteArray(sigStr), + msg="%d:%d:%d" % (hashType, idx, suite), + ) + + # Basic Multisig (compressed) + # For compressed keys + tests = ( + ( + "b78a743c0c6557f24a51192b82925942ebade0be86efd7dad58b9fa358d3857c", + "483045022100f12b12474e64b807eaeda6ac05b26d4b6bee2519385a84815f4ec2ccdf0aa45b022055c590d36a172c4735c8886572723037dc65329e70b8e5e012a9ec24993c284201483045022100ae2fec7236910b0bbc5eab37b7d987d61f22139f6381f2cc9781373e4f470c37022037d8b1658c2a83c40cc1b97036239eb0f4b313f3d2bf4558de33412e834c45d50147522102e11d2c0e415343435294079ac0774a21c8e6b1e6fd9b671cb08af43a397f3df1210224397bd81b0e80ec1bbfe104fb251b57eb0adcf044c3eec05d913e2e8e04396b52ae", + ), + ( + "a00616c21b117ba621d4c72faf30d30cd665416bdc3c24e549de2348ac68cfb8", + "473044022047b34afd287cacbc4ba0d95d985b23a55069c0bd81d61eb32435348bef2dc6c602201e4c7c0c437d4d53172cac355eadd70c8b87d3936c7a0a0179201b9b9327852d01483045022100df1975379ac38dcc5caddb1f55974b5b08a22b4fdb6e88be9ba12da0c0ecfbed022042bc3420adde7410f463caa998a460d58b214bf082e004b5067a4c0f061e0769014752210224397bd81b0e80ec1bbfe104fb251b57eb0adcf044c3eec05d913e2e8e04396b2103255f71eab9eb2a7e3f822569484448acbe2880d61b4db61020f73fd54cbe370d52ae", + ), + ( + "8902ea1f64c6fb7aa40dfbe798f5dc53b466a3fc01534e867581936a8ecbff5b", + "473044022002d1251cb8a2f1a20225948f99e6c71a188915c3ca0dc433ca9c35c050ee1dd602206880d041a9a9f9888ab751a371768bffd89251edf354eccdac73fe1376095ba20147304402204ddebf367aea5750123c2b4807815487d07239c776b6cc70a99c46a8b3261f4c022044549b4aeda7eb08692fa500b5518655be61fd5299c07adf0caddf41ab391dd00147522103255f71eab9eb2a7e3f822569484448acbe2880d61b4db61020f73fd54cbe370d2102e11d2c0e415343435294079ac0774a21c8e6b1e6fd9b671cb08af43a397f3df152ae", + ), + ) + + testingParams = mainnet + for hashType in hashTypes: + # TODO enable this test after script-hash script signing is implemented + break + for suite in signatureSuites: + for idx in range(len(tx.txIn)): + # var keyDB, pkBytes []byte + # var key chainec.PrivateKey + # var pk chainec.PublicKey + kStr, sigStr = tests[idx] + kStr2, _ = tests[(idx + 1) % 3] + + if suite == crypto.STEcdsaSecp256k1: + # k = Curve.generateKey(rand.Reader) + k = ByteArray(kStr) + k2 = ByteArray(kStr2) + privKey = crypto.privKeyFromBytes(k) + privKey2 = crypto.privKeyFromBytes(k2) + pkBytes = privKey.pub.serializeCompressed() + pkBytes2 = privKey2.pub.serializeCompressed() + else: + raise Exception( + "test for signature suite %d not implemented" % suite + ) + + address = crypto.AddressSecpPubKey(pkBytes.bytes(), testingParams) + + address2 = crypto.AddressSecpPubKey(pkBytes2.bytes(), testingParams) + + pkScript = txscript.multiSigScript([address, address2], 2) + + scriptAddr = crypto.newAddressScriptHash(pkScript, testingParams) + + scriptPkScript = txscript.payToAddrScript(scriptAddr) + + keys = iter([privKey, privKey2]) + + class keysource: + @staticmethod + def priv(addr): + return next(keys) + + sigScript = txscript.signTxOutput( + testingParams, + tx, + idx, + scriptPkScript, + hashType, + keysource, + None, + suite, + ) + print(sigScript.hex()) + + self.assertEqual( + sigScript, + ByteArray(sigStr), + msg="%d:%d:%d" % (hashType, idx, suite), + ) def test_sign_stake_p2pkh_outputs(self): from tinydecred.crypto.secp256k1 import curve as Curve @@ -2239,6 +2734,55 @@ def test_calc_signature_hash_reference(self): self.assertEqual(sigHash, expectedHash) + def test_scriptNumBytes(self): + tests = [ + (0, ByteArray()), + (1, ByteArray("01")), + (-1, ByteArray("81")), + (127, ByteArray("7f")), + (-127, ByteArray("ff")), + (128, ByteArray("8000")), + (-128, ByteArray("8080")), + (129, ByteArray("8100")), + (-129, ByteArray("8180")), + (256, ByteArray("0001")), + (-256, ByteArray("0081")), + (32767, ByteArray("ff7f")), + (-32767, ByteArray("ffff")), + (32768, ByteArray("008000")), + (-32768, ByteArray("008080")), + (65535, ByteArray("ffff00")), + (-65535, ByteArray("ffff80")), + (524288, ByteArray("000008")), + (-524288, ByteArray("000088")), + (7340032, ByteArray("000070")), + (-7340032, ByteArray("0000f0")), + (8388608, ByteArray("00008000")), + (-8388608, ByteArray("00008080")), + (2147483647, ByteArray("ffffff7f")), + (-2147483647, ByteArray("ffffffff")), + (2147483648, ByteArray("0000008000")), + (-2147483648, ByteArray("0000008080")), + (2415919104, ByteArray("0000009000")), + (-2415919104, ByteArray("0000009080")), + (4294967295, ByteArray("ffffffff00")), + (-4294967295, ByteArray("ffffffff80")), + (4294967296, ByteArray("0000000001")), + (-4294967296, ByteArray("0000000081")), + (281474976710655, ByteArray("ffffffffffff00")), + (-281474976710655, ByteArray("ffffffffffff80")), + (72057594037927935, ByteArray("ffffffffffffff00")), + (-72057594037927935, ByteArray("ffffffffffffff80")), + (9223372036854775807, ByteArray("ffffffffffffff7f")), + (-9223372036854775807, ByteArray("ffffffffffffffff")), + ] + + for num, serialized in tests: + gotBytes = txscript.scriptNumBytes(num) + assert gotBytes == serialized, ( + str(num) + ": wanted " + serialized.hex() + ", got " + gotBytes.hex() + ) + class TestVSP(unittest.TestCase): def setUp(self): diff --git a/ui/screens.py b/ui/screens.py index da661efb..b11c2654 100644 --- a/ui/screens.py +++ b/ui/screens.py @@ -1123,6 +1123,7 @@ def __init__(self, app): self.wgt.setContentsMargins(5, 5, 5, 5) self.wgt.setMinimumWidth(400) self.blockchain = app.dcrdata + self.revocableTicketsCount = 0 # Register for a few key signals. self.app.registerSignal(ui.BLOCKCHAIN_CONNECTED, self.blockchainConnected) @@ -1150,10 +1151,19 @@ def __init__(self, app): 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) + agendaBtn = app.getButton(TINY, "voting") + agendaBtn.clicked.connect(self.stackAgendas) + + # A button to revoke expired and missed tickets. + revokeBtn = app.getButton(TINY, "") + revokeBtn.clicked.connect(self.revokeTickets) + votingWgt, _ = Q.makeSeries(Q.HORIZONTAL, agendaBtn, revokeBtn) + self.revokeBtn = revokeBtn + self.layout.addWidget(votingWgt) + + # Hide revoke button unless we have revokable tickets. + revokeBtn.hide() + self.app.registerSignal(ui.SYNC_SIGNAL, self.checkRevocable) # Affordability. A row that reads `You can afford X tickets` lbl = Q.makeLabel("You can afford ", 14) @@ -1221,6 +1231,58 @@ def stackAgendas(self): return self.app.appWindow.stack(self.agendasScreen) + def checkRevocable(self): + """ + On SYNC_SIGNAL signal hide or show revoke button based on wether or not + we have revocable tickets. + """ + acct = self.app.wallet.selectedAccount + n = self.revocableTicketsCount + plural = "" + for utxo in acct.utxos.values(): + if utxo.isRevocableTicket(): + n += 1 + if n > 0: + if n > 1: + plural = "s" + self.revokeBtn.setText("revoke {} ticket{}".format(n, plural)) + self.revokeBtn.show() + else: + self.revokeBtn.hide() + + def revokeTickets(self): + """ + Revoke all revocable tickets. + """ + + def revoke(wallet): + try: + self.app.emitSignal(ui.WORKING_SIGNAL) + wallet.openAccount.revokeTickets() + return True + except Exception as e: + log.error("revoke tickets error: %s" % formatTraceback(e)) + return False + self.app.emitSignal(ui.DONE_SIGNAL) + + self.app.withUnlockedWallet(revoke, self.revoked) + + def revoked(self, success): + """ + revokeTickets callback. Prints success or failure to the screen. + """ + if success: + n = self.revocableTicketsCount + plural = "" + if n > 0: + if n > 1: + plural = "s" + self.app.appWindow.showSuccess("revoked {} ticket{}".format(n, plural)) + self.revocableTicketsCount = 0 + self.revokeBtn.hide() + else: + self.app.appWindow.showError("revoke tickets finished with error") + def setStats(self): """ Get the current ticket stats and update the display.