diff --git a/blockchain/stake/error.go b/blockchain/stake/error.go index 49dd5f8b..a1a765b8 100644 --- a/blockchain/stake/error.go +++ b/blockchain/stake/error.go @@ -1,5 +1,5 @@ // Copyright (c) 2014 Conformal Systems LLC. -// Copyright (c) 2015-2020 The Decred developers +// Copyright (c) 2015-2021 The Decred developers // Use of this source code is governed by an ISC // license that can be found in the LICENSE file. @@ -139,10 +139,13 @@ const ( // the stake tx tree. ErrSSRtxWrongTxTree = ErrorKind("ErrSSRtxWrongTxTree") - // ErrSSRtxBadGenOuts indicates that there was a non-SSRtx tagged output + // ErrSSRtxBadOuts indicates that there was a non-SSRtx tagged output // present in an SSRtx. ErrSSRtxBadOuts = ErrorKind("ErrSSRtxBadOuts") + // ErrSSRtxInvalidFee indicates that a given SSRtx contains an invalid fee. + ErrSSRtxInvalidFee = ErrorKind("ErrSSRtxInvalidFee") + // ErrVerSStxAmts indicates there was an error verifying the calculated // SStx out amounts and the actual SStx out amounts. ErrVerSStxAmts = ErrorKind("ErrVerSStxAmts") diff --git a/blockchain/stake/error_test.go b/blockchain/stake/error_test.go index 951babf6..07c66df1 100644 --- a/blockchain/stake/error_test.go +++ b/blockchain/stake/error_test.go @@ -1,5 +1,5 @@ // Copyright (c) 2014 Conformal Systems LLC. -// Copyright (c) 2015-2020 The Decred developers +// Copyright (c) 2015-2021 The Decred developers // Use of this source code is governed by an ISC // license that can be found in the LICENSE file. @@ -49,6 +49,7 @@ func TestErrorKindStringer(t *testing.T) { {ErrSSRtxNoOutputs, "ErrSSRtxNoOutputs"}, {ErrSSRtxWrongTxTree, "ErrSSRtxWrongTxTree"}, {ErrSSRtxBadOuts, "ErrSSRtxBadOuts"}, + {ErrSSRtxInvalidFee, "ErrSSRtxInvalidFee"}, {ErrVerSStxAmts, "ErrVerSStxAmts"}, {ErrVerifyInput, "ErrVerifyInput"}, {ErrVerifyOutType, "ErrVerifyOutType"}, diff --git a/blockchain/stake/staketx.go b/blockchain/stake/staketx.go index a4ddd19c..2a25d7da 100644 --- a/blockchain/stake/staketx.go +++ b/blockchain/stake/staketx.go @@ -15,6 +15,7 @@ import ( "math/big" "github.com/decred/dcrd/chaincfg/chainhash" + "github.com/decred/dcrd/chaincfg/v3" "github.com/decred/dcrd/dcrutil/v4" "github.com/decred/dcrd/txscript/v4" "github.com/decred/dcrd/txscript/v4/stdaddr" @@ -1221,3 +1222,122 @@ func FindSpentTicketsInBlock(block *wire.MsgBlock) *SpentTicketsInBlock { RevokedTickets: revocations, } } + +// CreateRevocationFromTicket creates a revocation transaction for the provided +// ticket. The provided fee is applied to the first output of the created +// revocation unless it does not adhere to the fee limit imposed by the ticket, +// in which case an error is returned. +func CreateRevocationFromTicket(ticketHash *chainhash.Hash, + ticketMinOuts []*MinimalOutput, revocationTxFee dcrutil.Amount, + revocationTxVersion uint16, params *chaincfg.Params) (*wire.MsgTx, error) { + + // Validate that the provided ticket minimal outputs have a non-zero length. + if len(ticketMinOuts) == 0 { + return nil, stakeRuleError(ErrSStxNoOutputs, "no minimal outputs") + } + + // Create the revocation transaction and add the input. The only input for a + // revocation transaction is a ticket submission (OP_SSTX tagged) output. + revocationTx := wire.NewMsgTx() + const ticketSubmissionOutput = 0 + const tree = wire.TxTreeStake + ticketSubmissionAmount := ticketMinOuts[ticketSubmissionOutput].Value + prevOut := wire.NewOutPoint(ticketHash, ticketSubmissionOutput, tree) + txIn := wire.NewTxIn(prevOut, ticketSubmissionAmount, nil) + revocationTx.AddTxIn(txIn) + + // Check to make sure that all scripts are the consensus version. + for i, ticketMinOut := range ticketMinOuts { + if ticketMinOut.Version != consensusVersion { + str := fmt.Sprintf("invalid script version found in ticket minimal "+ + "outputs idx %d", i) + return nil, stakeRuleError(ErrSStxInvalidOutputs, str) + } + } + + // Get the ticket commitment output details. + isP2SH, payToHashes, amounts, _, hasFeeLimit, feeLimits := + SStxStakeOutputInfo(ticketMinOuts) + + // In the returned ticket stake output values, vote fee limit info is in index + // 0 and revocation fee limit info is in index 1. + const revocationFeeLimitIndex = 1 + + // Calculate the revocation output values from this data. + const revocationSubsidy = 0 + revocationOutputAmounts := CalculateRewards(amounts, ticketSubmissionAmount, + revocationSubsidy) + + // Add all the SSRtx-tagged transaction outputs to the transaction after + // performing some validity checks. + feeApplied := false + for i, payToHash := range payToHashes { + // Ensure amount is in the valid range for monetary amounts. + if amounts[i] <= 0 || amounts[i] > dcrutil.MaxAmount { + str := fmt.Sprintf("invalid output amount: %v (min: 0, max: %v)", + amounts[i], dcrutil.MaxAmount) + return nil, stakeRuleError(ErrSStxBadCommitAmount, str) + } + + // Create a new script which pays to the provided address specified in + // the original ticket tx. + var ssrtxOutScript []byte + switch isP2SH[i] { + case false: // P2PKH + addr, err := stdaddr.NewAddressPubKeyHashEcdsaSecp256k1V0(payToHash, + params) + if err != nil { + return nil, err + } + _, ssrtxOutScript = addr.PayRevokeCommitmentScript() + case true: // P2SH + addr, err := stdaddr.NewAddressScriptHashV0FromHash(payToHash, params) + if err != nil { + return nil, err + } + _, ssrtxOutScript = addr.PayRevokeCommitmentScript() + } + + // Validate that the provided fee adheres to the fee limit imposed by the + // ticket commitment. + hasFeeLimit := hasFeeLimit[i][revocationFeeLimitIndex] + feeLimitLog2 := feeLimits[i][revocationFeeLimitIndex] + if hasFeeLimit { + // Notice that, since the fee limit is in terms of a log2 value, and + // amounts are int64, anything greater than or equal to 63 is interpreted + // to mean allow spending the entire amount as a fee. This allows fast + // left shifts to be used to calculate the fee limit while preventing + // degenerate cases such as negative numbers for 63. + if feeLimitLog2 < 63 { + feeLimit := int64(1 << uint64(feeLimitLog2)) + if !feeApplied && int64(revocationTxFee) > feeLimit { + str := fmt.Sprintf("fee %v is higher than the imposed fee limit %v", + int64(revocationTxFee), feeLimit) + return nil, stakeRuleError(ErrSSRtxInvalidFee, str) + } + } + } + + // The fee must be zero when not encumbered with a fee limit. + if !hasFeeLimit && revocationTxFee != 0 { + str := "fee must be zero when not encumbered with a fee limit" + return nil, stakeRuleError(ErrSSRtxInvalidFee, str) + } + + // Apply the fee to the output if the fee has not already been applied. + amt := revocationOutputAmounts[i] + if !feeApplied && int64(revocationTxFee) < amt { + amt -= int64(revocationTxFee) + feeApplied = true + } + + // Add the txout to our SSRtx tx. + txOut := wire.NewTxOut(amt, ssrtxOutScript) + revocationTx.AddTxOut(txOut) + } + + // Set the version of the revocation transaction. + revocationTx.Version = revocationTxVersion + + return revocationTx, nil +} diff --git a/blockchain/stake/staketx_test.go b/blockchain/stake/staketx_test.go index 06e85366..0b1ce204 100644 --- a/blockchain/stake/staketx_test.go +++ b/blockchain/stake/staketx_test.go @@ -6,12 +6,14 @@ package stake import ( "bytes" + "encoding/hex" "errors" "math/rand" "reflect" "testing" "github.com/decred/dcrd/chaincfg/chainhash" + "github.com/decred/dcrd/chaincfg/v3" "github.com/decred/dcrd/dcrutil/v4" "github.com/decred/dcrd/txscript/v4" "github.com/decred/dcrd/txscript/v4/stdaddr" @@ -30,6 +32,31 @@ const ( withTreasury = true ) +// hexToBytes converts the passed hex string into bytes and will panic if there +// is an error. This is only provided for the hard-coded constants so errors in +// the source code can be detected. It will only (and must only) be called with +// hard-coded values. +func hexToBytes(s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + panic("invalid hex in source file: " + s) + } + return b +} + +// mustParseHash converts the passed big-endian hex string into a +// chainhash.Hash and will panic if there is an error. It only differs from the +// one available in chainhash in that it will panic so errors in the source code +// be detected. It will only (and must only) be called with hard-coded, and +// therefore known good, hashes. +func mustParseHash(s string) *chainhash.Hash { + hash, err := chainhash.NewHashFromStr(s) + if err != nil { + panic("invalid hash in source file: " + s) + } + return hash +} + // SSTX TESTING ------------------------------------------------------------------- // TestSStx ensures the CheckSStx and IsSStx functions correctly recognize stake @@ -1326,6 +1353,202 @@ func TestIsNullDataScript(t *testing.T) { } } +// TestCreateRevocationFromTicket validates that revocation transactions are +// created correctly under a variety of conditions. +func TestCreateRevocationFromTicket(t *testing.T) { + t.Parallel() + + // Default network parameters to use for tests. + params := chaincfg.RegNetParams() + + // The following variables are derived from a mainnet ticket that was + // purchased in block 135375 and revoked in block 140709. + ticketHash := mustParseHash("dad48ac8c59ee97d1a6fd04ad4f1c8392357a6ee78d39f" + + "fc00fb467a0cdba695") + ticketOut1 := &MinimalOutput{ + PkScript: hexToBytes("ba76a914097e847d49c6806f6933e806a350f43b97ac70d088a" + + "c"), + Value: 4126629682, + Version: 0, + } + ticketOut2 := &MinimalOutput{ + PkScript: hexToBytes("6a1e86c6da62556f5e21fbce3564b7374724d65f0cbb51c66d0" + + "0000000800058"), + Value: 0, + Version: 0, + } + ticketOut3 := &MinimalOutput{ + PkScript: hexToBytes("bd76a914000000000000000000000000000000000000000088a" + + "c"), + Value: 0, + Version: 0, + } + ticketOut4 := &MinimalOutput{ + PkScript: hexToBytes("6a1e7e8efe653b374ba7ad950c873e483fc53c5bafa9c1c439f" + + "8000000000058"), + Value: 0, + Version: 0, + } + ticketOut5 := &MinimalOutput{ + PkScript: hexToBytes("bd76a914000000000000000000000000000000000000000088a" + + "c"), + Value: 0, + Version: 0, + } + ticketMinOuts := []*MinimalOutput{ + ticketOut1, + ticketOut2, + ticketOut3, + ticketOut4, + ticketOut5, + } + revocationHash := mustParseHash("46ae5f78174c6c6e3675d0bbfec27e25c40f3a119e" + + "df9183b96261db5cda7a4f") + revocationTxFee := dcrutil.Amount(285000) + revocationTxVersion := uint16(1) + + // Invalid script version. + ticketOutInvalidScriptVersion := &MinimalOutput{ + PkScript: hexToBytes("6a1e86c6da62556f5e21fbce3564b7374724d65f0cbb51c66d0" + + "0000000800058"), + Value: 0, + Version: 1, + } + ticketMinOutsInvalidScriptVersion := []*MinimalOutput{ + ticketOut1, + ticketOutInvalidScriptVersion, + ticketOut3, + ticketOut4, + ticketOut5, + } + + // Invalid ticket commitment amount. + ticketOutInvalidCommitmentAmt := &MinimalOutput{ + PkScript: hexToBytes("6a1e86c6da62556f5e21fbce3564b7374724d65f0cbbfffffff" + + "fffffffff0058"), + Value: 0, + Version: 0, + } + ticketMinOutsInvalidCommitmentAmt := []*MinimalOutput{ + ticketOut1, + ticketOutInvalidCommitmentAmt, + ticketOut3, + ticketOut4, + ticketOut5, + } + + // No fee limit. + ticketOut2NoFeeLimit := &MinimalOutput{ + PkScript: hexToBytes("6a1e86c6da62556f5e21fbce3564b7374724d65f0cbb51c66d0" + + "0000000800000"), + Value: 0, + Version: 0, + } + ticketMinOutsNoFeeLimit := []*MinimalOutput{ + ticketOut1, + ticketOut2NoFeeLimit, + ticketOut3, + ticketOut4, + ticketOut5, + } + + tests := []struct { + name string + ticketHash *chainhash.Hash + ticketMinOuts []*MinimalOutput + revocationTxFee dcrutil.Amount + revocationTxVersion uint16 + wantTxHash chainhash.Hash + wantErr error + }{{ + name: "valid with P2SH and P2PKH outputs", + ticketHash: ticketHash, + ticketMinOuts: ticketMinOuts, + revocationTxFee: revocationTxFee, + revocationTxVersion: revocationTxVersion, + wantTxHash: *revocationHash, + }, { + name: "invalid ticket minimal outputs", + ticketHash: ticketHash, + ticketMinOuts: nil, + revocationTxFee: revocationTxFee, + revocationTxVersion: revocationTxVersion, + wantErr: ErrSStxNoOutputs, + }, { + name: "invalid script version", + ticketHash: ticketHash, + ticketMinOuts: ticketMinOutsInvalidScriptVersion, + revocationTxFee: revocationTxFee, + revocationTxVersion: revocationTxVersion, + wantErr: ErrSStxInvalidOutputs, + }, { + name: "invalid ticket commitment amount", + ticketHash: ticketHash, + ticketMinOuts: ticketMinOutsInvalidCommitmentAmt, + revocationTxFee: revocationTxFee, + revocationTxVersion: revocationTxVersion, + wantErr: ErrSStxBadCommitAmount, + }, { + name: "invalid fee", + ticketHash: ticketHash, + ticketMinOuts: ticketMinOuts, + revocationTxFee: 16777217, + revocationTxVersion: revocationTxVersion, + wantErr: ErrSSRtxInvalidFee, + }, { + name: "invalid fee (no fee limit)", + ticketHash: ticketHash, + ticketMinOuts: ticketMinOutsNoFeeLimit, + revocationTxFee: revocationTxFee, + revocationTxVersion: revocationTxVersion, + wantErr: ErrSSRtxInvalidFee, + }} + + for _, test := range tests { + // Create a revocation transaction with the given test parameters. + revocationTx, err := CreateRevocationFromTicket(test.ticketHash, + test.ticketMinOuts, test.revocationTxFee, test.revocationTxVersion, + params) + + // Validate that the expected error was returned for negative tests. + if test.wantErr != nil { + if !errors.Is(err, test.wantErr) { + t.Errorf("%q: mismatched error -- got %T, want %T", test.name, err, + test.wantErr) + } + continue + } + + // Validate that an unexpected error was not returned. + if err != nil { + t.Fatalf("%q: unexpected error creating revocation: %v", test.name, err) + } + + // Validate that the revocation transaction was created correctly. + err = CheckSSRtx(revocationTx) + if err != nil { + t.Errorf("%q: unexpected error checking revocation: %v", test.name, err) + continue + } + + // Validate the revocation transaction version. + if revocationTx.Version != test.revocationTxVersion { + t.Errorf("%q: mismatched tx version -- got %d, want %d", test.name, + revocationTx.Version, test.revocationTxVersion) + continue + } + + // Validate that the resulting revocation transaction hash matches what is + // expected. + revocationTxHash := revocationTx.TxHash() + if revocationTxHash != test.wantTxHash { + t.Errorf("%q: mismatched tx hash -- got %v, want %v", test.name, + revocationTxHash, test.wantTxHash) + continue + } + } +} + // -------------------------------------------------------------------------------- // TESTING VARIABLES BEGIN HERE