stake: Add func to create revocation from ticket.

This adds a new function, CreateRevocationFromTicket, and associated
tests for creating a revocation transaction for a given ticket.
This commit is contained in:
Ryan Staudt 2021-08-12 17:09:26 -05:00 committed by Dave Collins
parent b4861dd9f0
commit 24a7bed319
4 changed files with 350 additions and 3 deletions

View File

@ -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")

View File

@ -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"},

View File

@ -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
}

View File

@ -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