From a69432bc18ef40f1b14706d3842a03a47c406242 Mon Sep 17 00:00:00 2001 From: Dave Collins Date: Wed, 22 Apr 2026 17:48:06 -0500 Subject: [PATCH 01/19] blockchain/stake: Remove commented test code. --- blockchain/stake/treasury_test.go | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/blockchain/stake/treasury_test.go b/blockchain/stake/treasury_test.go index 2b6827472a..00f6d29fe3 100644 --- a/blockchain/stake/treasury_test.go +++ b/blockchain/stake/treasury_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2020-2024 The Decred developers +// Copyright (c) 2020-2026 The Decred developers // Use of this source code is governed by an ISC // license that can be found in the LICENSE file. @@ -136,34 +136,6 @@ var ( } ) -// generateKeys generates all the keys that are hard coded in this file. -//func generateKeys() { -// key := secp256k1.PrivKeyFromBytes(privateKey) -// pubKey := key.PubKey() -// message := "test message" -// messageHash := chainhash.HashB([]byte(message)) -// signature, err := schnorr.Sign(key, messageHash) -// if err != nil { -// panic(err) -// } -// fmt.Printf("Sig 0x%x: %x\n", len(signature.Serialize()), -// signature.Serialize()) -// fmt.Printf("Public key 0x%x: %x\n", len(pubKey.SerializeCompressed()), -// pubKey.SerializeCompressed()) -// for k, v := range signature.Serialize() { -// if k%8 == 0 { -// fmt.Printf("\n") -// } -// fmt.Printf("0x%02x,", v) -// } -// fmt.Printf("\n") -//} -// -//func init() { -// generateKeys() -// panic("x") -//} - // newTxOut returns a new transaction output with the given parameters. func newTxOut(amount int64, pkScriptVer uint16, pkScript []byte) *wire.TxOut { return &wire.TxOut{ From 9c391a71ddd8cb5d8e5607ea53e8d8415cb7406f Mon Sep 17 00:00:00 2001 From: Dave Collins Date: Wed, 22 Apr 2026 17:48:06 -0500 Subject: [PATCH 02/19] blockchain/stake: Rework treasury tx type tests. This reworks the tests in TestTreasuryIsFunctions for the treasury add, treasurybase, and treasury spend identification funcs to make them more comprehensive, correct some that weren't actually testing what they claimed, and make them much more consistent with the other tests throughout the code base. Not only does it perform more comprehensive testing, it reduces the test code by about 42%. In particular: - Use hex to bytes for hard-coded byte slices for some of the globals instead of the much more verbose raw byte slices - Introduce helper functions to create the various components of the transactions - Start with well-formed transactions and modify them for each test instead of building them from scratch every time - Run all identification funcs against all of the transaction types to help ensure none of them are incorrectly detected as any other - Significantly improves readability and adds descriptions to make it clear for people not familiar with the code - Modernize the test formatting - Effectively add more tests overall due to cross testing - Correct test intending to pass stakebase but not treasury add and assert it actually passes the stakebase checks This is part of a larger overall effort to bring the treasury code up to the standards used throughout the rest of the blockchain consensus code. --- blockchain/stake/treasury_test.go | 633 +++++++++++------------------- 1 file changed, 237 insertions(+), 396 deletions(-) diff --git a/blockchain/stake/treasury_test.go b/blockchain/stake/treasury_test.go index 00f6d29fe3..31c23990a7 100644 --- a/blockchain/stake/treasury_test.go +++ b/blockchain/stake/treasury_test.go @@ -6,6 +6,7 @@ package stake import ( "bytes" + "encoding/binary" "encoding/hex" "errors" "math" @@ -14,7 +15,6 @@ import ( "github.com/decred/dcrd/chaincfg/chainhash" "github.com/decred/dcrd/chaincfg/v3" - "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/decred/dcrd/dcrutil/v4" "github.com/decred/dcrd/txscript/v4" "github.com/decred/dcrd/txscript/v4/stdaddr" @@ -24,33 +24,17 @@ import ( // Private and public keys for tests. var ( // Serialized private key. - //privateKey = []byte{ - // 0x76, 0x87, 0x56, 0x13, 0x94, 0xcc, 0xc6, 0x11, - // 0x01, 0x51, 0xbd, 0x9f, 0x26, 0xd4, 0x22, 0x8e, - // 0xb2, 0xd5, 0x7b, 0xe1, 0x28, 0xc0, 0x36, 0x12, - // 0xe3, 0x9a, 0x84, 0x4a, 0x3e, 0xcd, 0x3c, 0xcf, - //} + // privateKey = hexToBytes("7687561394ccc6110151bd9f26d4228eb2d57be128c036" + + // "12e39a844a3ecd3ccf") // Serialized compressed public key. - publicKey = []byte{ - 0x02, 0xa4, 0xf6, 0x45, 0x86, 0xe1, 0x72, 0xc3, - 0xd9, 0xa2, 0x0c, 0xfa, 0x6c, 0x7a, 0xc8, 0xfb, - 0x12, 0xf0, 0x11, 0x5b, 0x3f, 0x69, 0xc3, 0xc3, - 0x5a, 0xec, 0x93, 0x3a, 0x4c, 0x47, 0xc7, 0xd9, - 0x2c, - } + publicKey = hexToBytes("02a4f64586e172c3d9a20cfa6c7ac8fb12f0115b3f69c3c3" + + "5aec933a4c47c7d92c") // Valid signature of chainhash.HashB([]byte("test message")) - validSignature = []byte{ - 0x77, 0x69, 0x84, 0xf6, 0x83, 0x13, 0xb1, 0xac, - 0x62, 0x9e, 0x62, 0x4a, 0xf0, 0x59, 0x5b, 0xdc, - 0x09, 0xd8, 0xde, 0xd0, 0x2b, 0xc2, 0xb2, 0x9f, - 0xbd, 0xb3, 0x95, 0x95, 0xe0, 0x3a, 0xc8, 0xb0, - 0xcf, 0x81, 0x8c, 0xa5, 0x36, 0x72, 0x3e, 0x63, - 0x90, 0xd3, 0x08, 0x4e, 0x0e, 0x31, 0xc7, 0x94, - 0x22, 0x29, 0x15, 0x3c, 0xe3, 0x4d, 0x87, 0x39, - 0x29, 0xb1, 0x60, 0x88, 0xd9, 0xe1, 0xaf, 0x43, - } + validSignature = hexToBytes("776984f68313b1ac629e624af0595bdc09d8ded02bc2" + + "b29fbdb39595e03ac8b0cf818ca536723e6390d3084e0e31c7942229153ce34d8739" + + "29b16088d9e1af43") // OP_DATA_64 OP_TSPEND tspendValidKey = []byte{ @@ -136,6 +120,61 @@ var ( } ) +// opReturnScript returns a provably-pruneable OP_RETURN script with the +// provided data. +func opReturnScript(data []byte) []byte { + builder := txscript.NewScriptBuilder() + script, err := builder.AddOp(txscript.OP_RETURN).AddData(data).Script() + if err != nil { + panic(err) + } + return script +} + +// treasurybaseOpReturnScript returns a script suitable for use as the second +// output of the treasurybase transaction of a new block. In particular, the +// serialized data used with the OP_RETURN starts with the block height and is +// followed by 8 bytes of cryptographically random data. +func treasurybaseOpReturnScript(blockHeight uint32) []byte { + data := make([]byte, 12) + binary.LittleEndian.PutUint32(data[0:4], blockHeight) + binary.LittleEndian.PutUint64(data[4:12], rand.Uint64()) + return opReturnScript(data) +} + +// treasurySpendOpReturnScript returns a script suitable for use as the first +// output of a treasury spend transaction. In particular, the serialized data +// used with the OP_RETURN starts with the total spend amount and is followed by +// 24 bytes of cryptographically random data. +func treasurySpendOpReturnScript(amount int64) []byte { + data := make([]byte, 32) + binary.LittleEndian.PutUint64(data[0:8], uint64(amount)) + rand.Read(data[8:]) + return opReturnScript(data) +} + +// treasurySpendSignature returns a treasury spend signature script with the +// provided signature and public key. +func treasurySpendSignature(sig, pubKey []byte) []byte { + builder := txscript.NewScriptBuilder() + builder.AddData(sig) + builder.AddData(pubKey) + builder.AddOp(txscript.OP_TSPEND) + script, err := builder.Script() + if err != nil { + panic(err) + } + return script +} + +// fakeTreasurySpendSignature returns a signature script that is valid enough to +// pass all checks, but would fail if actually checked. This identification +// funcs in this package do not verify signatures, so valid signatures are not +// required for the tests. +func fakeTreasurySpendSignature() []byte { + return treasurySpendSignature(validSignature, publicKey) +} + // newTxOut returns a new transaction output with the given parameters. func newTxOut(amount int64, pkScriptVer uint16, pkScript []byte) *wire.TxOut { return &wire.TxOut{ @@ -145,398 +184,200 @@ func newTxOut(amount int64, pkScriptVer uint16, pkScript []byte) *wire.TxOut { } } -// TestTreasuryIsFunctions goes through all valid treasury opcode combinations. +var ( + // opTrueScript is a simple public key script that contains the OP_TRUE + // opcode. + opTrueScript = []byte{txscript.OP_TRUE} + + // p2shOpTrueAddr is a pay-to-script-hash address that can be redeemed with + // [opTrueScript]. + p2shOpTrueAddr = func() *stdaddr.AddressScriptHashV0 { + params := chaincfg.RegNetParams() + addr, err := stdaddr.NewAddressScriptHashV0(opTrueScript, params) + if err != nil { + panic(err) + } + return addr + }() + + // baseTreasuryAddTx is a valid treasury add transaction that includes a + // change output. It is used as a base to be further manipulated in the + // tests. + baseTreasuryAddTx = func() *wire.MsgTx { + changeScriptVer, changeScript := p2shOpTrueAddr.StakeChangeScript() + + tx := wire.NewMsgTx() + tx.Version = wire.TxVersionTreasury + tx.AddTxIn(&wire.TxIn{}) // One input required + tx.AddTxOut(newTxOut(0, 0, []byte{txscript.OP_TADD})) + tx.AddTxOut(newTxOut(1, changeScriptVer, changeScript)) + return tx + }() + + // baseTreasuryBaseTx is a valid treasury base transaction that commits to a + // random height. It is used as a base to be further manipulated in the + // tests. + baseTreasuryBaseTx = func() *wire.MsgTx { + tx := wire.NewMsgTx() + tx.Version = wire.TxVersionTreasury + tx.AddTxIn(&wire.TxIn{ + // Treasurybase transactions have no inputs, so previous outpoint is + // zero hash and max index. + PreviousOutPoint: *wire.NewOutPoint(zeroHash, wire.MaxPrevOutIndex, + wire.TxTreeRegular), + Sequence: wire.MaxTxInSequenceNum, + ValueIn: 0, + BlockHeight: wire.NullBlockHeight, + BlockIndex: wire.NullBlockIndex, + SignatureScript: nil, // Must be nil by consensus. + }) + tx.AddTxOut(newTxOut(0, 0, []byte{txscript.OP_TADD})) + tx.AddTxOut(newTxOut(0, 0, treasurybaseOpReturnScript(rand.Uint32()))) + return tx + }() + + // baseTreasurySpendTx is a valid treasury spend transaction that pays to a + // p2sh script. It is used as a base to be further manipulated in the + // tests. + baseTreasurySpendTx = func() *wire.MsgTx { + const payout = 1e8 + const fee = 5000 + payoutScriptVer, payoutScript := p2shOpTrueAddr.PayFromTreasuryScript() + + tx := wire.NewMsgTx() + tx.Version = wire.TxVersionTreasury + tx.AddTxIn(&wire.TxIn{ + // Treasury spend transactions have no inputs, so previous outpoint + // is zero hash and max index. + PreviousOutPoint: *wire.NewOutPoint(zeroHash, wire.MaxPrevOutIndex, + wire.TxTreeRegular), + Sequence: wire.MaxTxInSequenceNum, + ValueIn: fee + payout, + BlockHeight: wire.NullBlockHeight, + BlockIndex: wire.NullBlockIndex, + SignatureScript: fakeTreasurySpendSignature(), + }) + tx.AddTxOut(newTxOut(0, 0, treasurySpendOpReturnScript(payout))) + tx.AddTxOut(newTxOut(0, payoutScriptVer, payoutScript)) + return tx + }() +) + +// TestTreasuryIsFunctions confirms the various treasury transaction type +// identification functions return the expected results. Each transaction is +// tested against all funcs to help ensure none of them are incorrectly detected +// as any other. func TestTreasuryIsFunctions(t *testing.T) { tests := []struct { - name string - createTx func() *wire.MsgTx - is func(*wire.MsgTx) bool - expected bool - check func(*wire.MsgTx) error + name string // test description + tx *wire.MsgTx // transaction to test + treasuryAdd bool // expected check is treasury add + treasuryBase bool // expected is treasury base + treasurySpend bool // expected is treasury spend }{{ - name: "tadd from user, no change", - createTx: func() *wire.MsgTx { - builder := txscript.NewScriptBuilder() - builder.AddOp(txscript.OP_TADD) - script, err := builder.Script() - if err != nil { - panic(err) - } - msgTx := wire.NewMsgTx() - msgTx.Version = wire.TxVersionTreasury - msgTx.AddTxOut(wire.NewTxOut(0, script)) - msgTx.AddTxIn(&wire.TxIn{}) // One input required - return msgTx - }, - is: IsTAdd, - expected: true, - check: checkTAdd, + name: "treasury add from user with change", + tx: baseTreasuryAddTx, + treasuryAdd: true, }, { - name: "check tadd from user, no change with istreasurybase", - createTx: func() *wire.MsgTx { - builder := txscript.NewScriptBuilder() - builder.AddOp(txscript.OP_TADD) - script, err := builder.Script() - if err != nil { - panic(err) - } - msgTx := wire.NewMsgTx() - msgTx.Version = wire.TxVersionTreasury - msgTx.AddTxOut(wire.NewTxOut(0, script)) - msgTx.AddTxIn(&wire.TxIn{}) // One input required - return msgTx - }, - is: IsTreasuryBase, - expected: false, - check: checkTreasuryBase, + name: "treasury add from user with no change", + tx: func() *wire.MsgTx { + tx := baseTreasuryAddTx.Copy() + tx.TxOut = tx.TxOut[:1] + return tx + }(), + treasuryAdd: true, }, { - // This is a valid stakebase but NOT a valid TADD. - name: "tadd from user, with OP_RETURN", - createTx: func() *wire.MsgTx { - builder := txscript.NewScriptBuilder() - builder.AddOp(txscript.OP_TADD) - script, err := builder.Script() - if err != nil { - panic(err) - } - msgTx := wire.NewMsgTx() - msgTx.Version = wire.TxVersionTreasury - msgTx.AddTxOut(wire.NewTxOut(0, script)) - - // OP_RETURN - payload := make([]byte, chainhash.HashSize) - _, err = rand.Read(payload) - if err != nil { - panic(err) - } - builder = txscript.NewScriptBuilder() - builder.AddOp(txscript.OP_RETURN) - builder.AddData(payload) - script, err = builder.Script() - if err != nil { - panic(err) - } - msgTx.AddTxOut(wire.NewTxOut(0, script)) - - msgTx.AddTxIn(&wire.TxIn{ - // Stakebase transactions have no - // inputs, so previous outpoint is zero - // hash and max index. - PreviousOutPoint: *wire.NewOutPoint(&chainhash.Hash{}, - wire.MaxPrevOutIndex, wire.TxTreeRegular), - Sequence: wire.MaxTxInSequenceNum, - BlockHeight: wire.NullBlockHeight, - BlockIndex: wire.NullBlockIndex, - SignatureScript: []byte{txscript.OP_TRUE}, + // This passes stakebase checks but is NOT a valid TADD. + name: "treasury add from user with OP_RETURN", + tx: func() *wire.MsgTx { + params := chaincfg.RegNetParams() + + const voteSubsidy = 1e8 + const ticketPrice = 2e8 + tx := baseTreasuryBaseTx.Copy() + tx.TxIn[0].ValueIn = voteSubsidy + tx.TxIn[0].SignatureScript = params.StakeBaseSigScript + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: *wire.NewOutPoint(zeroHash, 0, wire.TxTreeStake), + Sequence: wire.MaxTxInSequenceNum, + ValueIn: ticketPrice, + BlockHeight: wire.NullBlockHeight, + BlockIndex: wire.NullBlockIndex, + SignatureScript: opTrueScript, }) - return msgTx - }, - is: IsTAdd, - expected: false, - check: checkTAdd, - }, { - name: "tadd from user, with change", - createTx: func() *wire.MsgTx { - builder := txscript.NewScriptBuilder() - builder.AddOp(txscript.OP_TADD) - script, err := builder.Script() - if err != nil { - panic(err) - } - msgTx := wire.NewMsgTx() - msgTx.Version = wire.TxVersionTreasury - msgTx.AddTxOut(wire.NewTxOut(0, script)) - - opTrueScript := []byte{txscript.OP_TRUE} - p2shOpTrueAddr, err := stdaddr.NewAddressScriptHashV0(opTrueScript, - chaincfg.MainNetParams()) - if err != nil { - panic(err) + if !IsStakeBase(tx) { + panic("transaction does not pass stakebase checks") } - changeScriptVer, changeScript := p2shOpTrueAddr.StakeChangeScript() - msgTx.AddTxOut(newTxOut(1, changeScriptVer, changeScript)) - msgTx.AddTxIn(&wire.TxIn{}) // One input required - return msgTx - }, - is: IsTAdd, - expected: true, - check: checkTAdd, + return tx + }(), }, { - name: "tadd from treasurybase", - createTx: func() *wire.MsgTx { - builder := txscript.NewScriptBuilder() - builder.AddOp(txscript.OP_TADD) - script, err := builder.Script() - if err != nil { - panic(err) - } - msgTx := wire.NewMsgTx() - msgTx.Version = wire.TxVersionTreasury - msgTx.AddTxOut(wire.NewTxOut(0, script)) - - // OP_RETURN - payload := make([]byte, 12) - _, err = rand.Read(payload) - if err != nil { - panic(err) - } - builder = txscript.NewScriptBuilder() - builder.AddOp(txscript.OP_RETURN) - builder.AddData(payload) - script, err = builder.Script() - if err != nil { - panic(err) - } - msgTx.AddTxOut(wire.NewTxOut(0, script)) - - // treasurybase - msgTx.AddTxIn(&wire.TxIn{ - // Stakebase transactions have no - // inputs, so previous outpoint is zero - // hash and max index. - PreviousOutPoint: *wire.NewOutPoint(&chainhash.Hash{}, - wire.MaxPrevOutIndex, wire.TxTreeRegular), - Sequence: wire.MaxTxInSequenceNum, - BlockHeight: wire.NullBlockHeight, - BlockIndex: wire.NullBlockIndex, - SignatureScript: nil, - }) - - return msgTx - }, - is: IsTreasuryBase, - expected: true, - check: checkTreasuryBase, + name: "treasury add from treasurybase", + tx: baseTreasuryBaseTx, + treasuryBase: true, }, { - name: "check treasury base with tadd", - createTx: func() *wire.MsgTx { - builder := txscript.NewScriptBuilder() - builder.AddOp(txscript.OP_TADD) - script, err := builder.Script() - if err != nil { - panic(err) - } - msgTx := wire.NewMsgTx() - msgTx.Version = wire.TxVersionTreasury - msgTx.AddTxOut(wire.NewTxOut(0, script)) - - // OP_RETURN - payload := make([]byte, 12) - _, err = rand.Read(payload) - if err != nil { - panic(err) - } - builder = txscript.NewScriptBuilder() - builder.AddOp(txscript.OP_RETURN) - builder.AddData(payload) - script, err = builder.Script() - if err != nil { - panic(err) - } - msgTx.AddTxOut(wire.NewTxOut(0, script)) - - msgTx.AddTxIn(&wire.TxIn{ - // Stakebase transactions have no - // inputs, so previous outpoint is zero - // hash and max index. - PreviousOutPoint: *wire.NewOutPoint(&chainhash.Hash{}, - wire.MaxPrevOutIndex, wire.TxTreeRegular), - Sequence: wire.MaxTxInSequenceNum, - BlockHeight: wire.NullBlockHeight, - BlockIndex: wire.NullBlockIndex, - SignatureScript: nil, - }) - - return msgTx - }, - is: IsTAdd, - expected: false, - check: checkTAdd, + name: "treasury spend p2sh", + tx: baseTreasurySpendTx, + treasurySpend: true, }, { - name: "tspend P2SH", - createTx: func() *wire.MsgTx { - // OP_RETURN <32 byte random> - payload := make([]byte, chainhash.HashSize) - _, err := rand.Read(payload) - if err != nil { - panic(err) - } - builder := txscript.NewScriptBuilder() - builder.AddOp(txscript.OP_RETURN) - builder.AddData(payload) - opretScript, err := builder.Script() - if err != nil { - panic(err) - } - msgTx := wire.NewMsgTx() - msgTx.Version = wire.TxVersionTreasury - msgTx.AddTxOut(wire.NewTxOut(0, opretScript)) - - // OP_TGEN - opTrueScript := []byte{txscript.OP_TRUE} - p2shOpTrueAddr, err := stdaddr.NewAddressScriptHashV0(opTrueScript, - chaincfg.MainNetParams()) - if err != nil { - panic(err) - } - genScriptVer, genScript := p2shOpTrueAddr.PayFromTreasuryScript() - msgTx.AddTxOut(newTxOut(0, genScriptVer, genScript)) - - // tspend - builder = txscript.NewScriptBuilder() - builder.AddData(validSignature) - builder.AddData(publicKey) - builder.AddOp(txscript.OP_TSPEND) - tspendScript, err := builder.Script() - if err != nil { - panic(err) - } - - msgTx.AddTxIn(&wire.TxIn{ - // Stakebase transactions have no - // inputs, so previous outpoint is zero - // hash and max index. - PreviousOutPoint: *wire.NewOutPoint(&chainhash.Hash{}, - wire.MaxPrevOutIndex, wire.TxTreeRegular), - Sequence: wire.MaxTxInSequenceNum, - BlockHeight: wire.NullBlockHeight, - BlockIndex: wire.NullBlockIndex, - SignatureScript: tspendScript, - }) - - return msgTx - }, - is: IsTSpend, - expected: true, - check: checkTSpend, - }, { - name: "tspend invalid output 1 (not P2SH/P2PKH)", - createTx: func() *wire.MsgTx { - // OP_RETURN <32 byte random> - payload := make([]byte, chainhash.HashSize) - _, err := rand.Read(payload) - if err != nil { - panic(err) - } - builder := txscript.NewScriptBuilder() - builder.AddOp(txscript.OP_RETURN) - builder.AddData(payload) - opretScript, err := builder.Script() - if err != nil { - panic(err) - } - msgTx := wire.NewMsgTx() - msgTx.Version = wire.TxVersionTreasury - msgTx.AddTxOut(wire.NewTxOut(0, opretScript)) - - // OP_TGEN - privKey := secp256k1.NewPrivateKey(new(secp256k1.ModNScalar).SetInt(1)) - pubKey := privKey.PubKey().SerializeCompressed() - p2pkAddr, err := stdaddr.NewAddressPubKeyEcdsaSecp256k1V0Raw(pubKey, - chaincfg.MainNetParams()) - if err != nil { - panic(err) - } - p2pkScriptVer, p2pkScript := p2pkAddr.PaymentScript() - script := make([]byte, len(p2pkScript)+1) - script[0] = txscript.OP_TGEN - copy(script[1:], p2pkScript) - msgTx.AddTxOut(newTxOut(0, p2pkScriptVer, script)) - - // tspend - builder = txscript.NewScriptBuilder() - builder.AddData(validSignature) - builder.AddData(publicKey) - builder.AddOp(txscript.OP_TSPEND) - tspendScript, err := builder.Script() - if err != nil { - panic(err) - } - - msgTx.AddTxIn(&wire.TxIn{ - // Stakebase transactions have no - // inputs, so previous outpoint is zero - // hash and max index. - PreviousOutPoint: *wire.NewOutPoint(&chainhash.Hash{}, - wire.MaxPrevOutIndex, wire.TxTreeRegular), - Sequence: wire.MaxTxInSequenceNum, - BlockHeight: wire.NullBlockHeight, - BlockIndex: wire.NullBlockIndex, - SignatureScript: tspendScript, - }) - - return msgTx - }, - is: IsTSpend, - expected: false, - check: checkTSpend, - }, { - name: "tspend P2PKH", - createTx: func() *wire.MsgTx { - // OP_RETURN <32 byte random> - payload := make([]byte, chainhash.HashSize) - _, err := rand.Read(payload) - if err != nil { - panic(err) - } - builder := txscript.NewScriptBuilder() - builder.AddOp(txscript.OP_RETURN) - builder.AddData(payload) - opretScript, err := builder.Script() - if err != nil { - panic(err) - } - msgTx := wire.NewMsgTx() - msgTx.Version = wire.TxVersionTreasury - msgTx.AddTxOut(wire.NewTxOut(0, opretScript)) - - // OP_TGEN - privKey := secp256k1.NewPrivateKey(new(secp256k1.ModNScalar).SetInt(1)) - pubKey := privKey.PubKey() - pkHash := stdaddr.Hash160(pubKey.SerializeCompressed()) + name: "treasury spend p2pkh", + tx: func() *wire.MsgTx { + params := chaincfg.RegNetParams() + pkHash := stdaddr.Hash160(publicKey) p2pkhAddr, err := stdaddr.NewAddressPubKeyHashEcdsaSecp256k1V0( - pkHash, chaincfg.MainNetParams()) + pkHash, params) if err != nil { panic(err) } - genScriptVer, genScript := p2pkhAddr.PayFromTreasuryScript() - msgTx.AddTxOut(newTxOut(0, genScriptVer, genScript)) - - // tspend - builder = txscript.NewScriptBuilder() - builder.AddData(validSignature) - builder.AddData(publicKey) - builder.AddOp(txscript.OP_TSPEND) - tspendScript, err := builder.Script() + payoutScriptVer, payoutScript := p2pkhAddr.PayFromTreasuryScript() + + tx := baseTreasurySpendTx.Copy() + tx.TxOut[1].Version = payoutScriptVer + tx.TxOut[1].PkScript = payoutScript + return tx + }(), + treasurySpend: true, + }, { + name: "treasury spend invalid output 1 p2pk (not p2sh/p2pkh)", + tx: func() *wire.MsgTx { + // Start with a normal payment script for the p2pk and manually add + // the OP_TGEN prefix since there is no standard method to create + // the pay from treasury script on a p2pk address given it is + // invalid. + params := chaincfg.RegNetParams() + p2pkAddr, err := stdaddr.NewAddressPubKeyEcdsaSecp256k1V0Raw( + publicKey, params) if err != nil { panic(err) } + payoutScriptVer, payScript := p2pkAddr.PaymentScript() + payoutScript := make([]byte, len(payScript)+1) + payoutScript[0] = txscript.OP_TGEN + copy(payoutScript[1:], payScript) + + tx := baseTreasurySpendTx.Copy() + tx.TxOut[1].Version = payoutScriptVer + tx.TxOut[1].PkScript = payoutScript + return tx + }(), + }} - msgTx.AddTxIn(&wire.TxIn{ - // Stakebase transactions have no - // inputs, so previous outpoint is zero - // hash and max index. - PreviousOutPoint: *wire.NewOutPoint(&chainhash.Hash{}, - wire.MaxPrevOutIndex, wire.TxTreeRegular), - Sequence: wire.MaxTxInSequenceNum, - BlockHeight: wire.NullBlockHeight, - BlockIndex: wire.NullBlockIndex, - SignatureScript: tspendScript, - }) + for _, test := range tests { + gotTreasuryAdd := IsTAdd(test.tx) + if gotTreasuryAdd != test.treasuryAdd { + t.Errorf("%s: unexpected treasury add result - got %v, want %v", + test.name, gotTreasuryAdd, test.treasuryAdd) + } - return msgTx - }, - is: IsTSpend, - expected: true, - check: checkTSpend, - }} + gotTreasuryBase := IsTreasuryBase(test.tx) + if gotTreasuryBase != test.treasuryBase { + t.Errorf("%s: unexpected treasurybase result - got %v, want %v", + test.name, gotTreasuryBase, test.treasuryBase) + } - for i, test := range tests { - if got := test.is(test.createTx()); got != test.expected { - // Obtain error - err := test.check(test.createTx()) - t.Fatalf("%v %v: failed got %v want %v error %v", - i, test.name, got, test.expected, err) + gotTreasurySpend := IsTSpend(test.tx) + if gotTreasurySpend != test.treasurySpend { + t.Errorf("%s: unexpected treasury spend result - got %v, want %v", + test.name, gotTreasurySpend, test.treasurySpend) } } } From 605214b513450807d87576a81783193f3e581dea Mon Sep 17 00:00:00 2001 From: Dave Collins Date: Wed, 22 Apr 2026 17:48:07 -0500 Subject: [PATCH 03/19] blockchain/stake: Remove duplicate test. Now that the updated treasury spend tests cover the fully valid case, there is no benefit to repeating it in another test. This is part of a larger overall effort to bring the treasury code up to the standards used throughout the rest of the blockchain consensus code. --- blockchain/stake/treasury_test.go | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/blockchain/stake/treasury_test.go b/blockchain/stake/treasury_test.go index 31c23990a7..300472d020 100644 --- a/blockchain/stake/treasury_test.go +++ b/blockchain/stake/treasury_test.go @@ -5,9 +5,7 @@ package stake import ( - "bytes" "encoding/binary" - "encoding/hex" "errors" "math" "math/rand" @@ -839,25 +837,6 @@ var tspendInvalidTxVersion = &wire.MsgTx{ Expiry: 0, } -func TestTSpendGenerated(t *testing.T) { - rawScript := "03000000010000000000000000000000000000000000000000000000000000000000000000ffffffff00ffffffff0200000000000000000000226a20562ce42e7531d1710ea1ee02628191190ef5152bbbcd23acca864433c4e4e7849cf1052a01000000000018c3a914f5a8302ee8695bf836258b8f2b57b38a0be14e478700000000520000000100f2052a0100000000000000ffffffff64408ea1c04f5e5dd59350847fad8b800887200ae7268da3b70488a605dd5f4ad28e6e240dbd483a8ba46324a047cf0d6c506e6ebb61d93cae6e868b86f31d9bda892103b459ccf3ce4935a676414fd9ec93ecf7c9dad081a52ed6993bf073c627499388c2" - s, err := hex.DecodeString(rawScript) - if err != nil { - t.Fatal(err) - } - var tx wire.MsgTx - err = tx.Deserialize(bytes.NewReader(s)) - if err != nil { - t.Fatalf("Deserialize: %v", err) - } - tx.Version = wire.TxVersionTreasury - - err = checkTSpend(&tx) - if err != nil { - t.Fatalf("checkTSpend: %v", err) - } -} - func TestTSpendErrors(t *testing.T) { tests := []struct { name string From 0175551281572fbc05949bb354b72d8533f6d8da Mon Sep 17 00:00:00 2001 From: Dave Collins Date: Wed, 22 Apr 2026 17:48:07 -0500 Subject: [PATCH 04/19] blockchain/stake: Rework treasury spend err tests. This reworks the treasury spend error tests to use the newly introduced functions that start with a valid treasury spend and then mutates a copy to induce the specific error to test. In the process, it also corrects some tests that weren't actually tsting what they claimed. The result is significantly more readable, provides more comprehensive test coverage, is more consistent with the other tests throughout the code base, and reduces the test code for the relevant tests by about 69%. This is part of a larger overall effort to bring the treasury code up to the standards used throughout the rest of the blockchain consensus code. --- blockchain/stake/treasury_test.go | 824 +++++++----------------------- 1 file changed, 194 insertions(+), 630 deletions(-) diff --git a/blockchain/stake/treasury_test.go b/blockchain/stake/treasury_test.go index 300472d020..9d1f9f3b7b 100644 --- a/blockchain/stake/treasury_test.go +++ b/blockchain/stake/treasury_test.go @@ -33,89 +33,6 @@ var ( validSignature = hexToBytes("776984f68313b1ac629e624af0595bdc09d8ded02bc2" + "b29fbdb39595e03ac8b0cf818ca536723e6390d3084e0e31c7942229153ce34d8739" + "29b16088d9e1af43") - - // OP_DATA_64 OP_TSPEND - tspendValidKey = []byte{ - 0x40, // OP_DATA_64 valid signature - 0x77, 0x69, 0x84, 0xf6, 0x83, 0x13, 0xb1, 0xac, - 0x62, 0x9e, 0x62, 0x4a, 0xf0, 0x59, 0x5b, 0xdc, - 0x09, 0xd8, 0xde, 0xd0, 0x2b, 0xc2, 0xb2, 0x9f, - 0xbd, 0xb3, 0x95, 0x95, 0xe0, 0x3a, 0xc8, 0xb0, - 0xcf, 0x81, 0x8c, 0xa5, 0x36, 0x72, 0x3e, 0x63, - 0x90, 0xd3, 0x08, 0x4e, 0x0e, 0x31, 0xc7, 0x94, - 0x22, 0x29, 0x15, 0x3c, 0xe3, 0x4d, 0x87, 0x39, - 0x29, 0xb1, 0x60, 0x88, 0xd9, 0xe1, 0xaf, 0x43, - 0x21, // OP_DATA_33 valid public key - 0x02, 0xa4, 0xf6, 0x45, 0x86, 0xe1, 0x72, 0xc3, - 0xd9, 0xa2, 0x0c, 0xfa, 0x6c, 0x7a, 0xc8, 0xfb, - 0x12, 0xf0, 0x11, 0x5b, 0x3f, 0x69, 0xc3, 0xc3, - 0x5a, 0xec, 0x93, 0x3a, 0x4c, 0x47, 0xc7, 0xd9, - 0x2c, - 0xc2, // OP_TSPEND - } - - // OP_DATA_64 - tspendNoTSpend = []byte{ - 0x40, // OP_DATA_64 valid signature - 0x77, 0x69, 0x84, 0xf6, 0x83, 0x13, 0xb1, 0xac, - 0x62, 0x9e, 0x62, 0x4a, 0xf0, 0x59, 0x5b, 0xdc, - 0x09, 0xd8, 0xde, 0xd0, 0x2b, 0xc2, 0xb2, 0x9f, - 0xbd, 0xb3, 0x95, 0x95, 0xe0, 0x3a, 0xc8, 0xb0, - 0xcf, 0x81, 0x8c, 0xa5, 0x36, 0x72, 0x3e, 0x63, - 0x90, 0xd3, 0x08, 0x4e, 0x0e, 0x31, 0xc7, 0x94, - 0x22, 0x29, 0x15, 0x3c, 0xe3, 0x4d, 0x87, 0x39, - 0x29, 0xb1, 0x60, 0x88, 0xd9, 0xe1, 0xaf, 0x43, - 0x21, // OP_DATA_33 valid public key - 0x02, 0xa4, 0xf6, 0x45, 0x86, 0xe1, 0x72, 0xc3, - 0xd9, 0xa2, 0x0c, 0xfa, 0x6c, 0x7a, 0xc8, 0xfb, - 0x12, 0xf0, 0x11, 0x5b, 0x3f, 0x69, 0xc3, 0xc3, - 0x5a, 0xec, 0x93, 0x3a, 0x4c, 0x47, 0xc7, 0xd9, - 0x2c, // No OP_TSPEND - } - - // nolint: dupword - // - // OP_DATA_64 OP_TSPEND OP_TSPEND - tspendTwoTSpend = []byte{ - 0x40, // OP_DATA_64 valid signature - 0x77, 0x69, 0x84, 0xf6, 0x83, 0x13, 0xb1, 0xac, - 0x62, 0x9e, 0x62, 0x4a, 0xf0, 0x59, 0x5b, 0xdc, - 0x09, 0xd8, 0xde, 0xd0, 0x2b, 0xc2, 0xb2, 0x9f, - 0xbd, 0xb3, 0x95, 0x95, 0xe0, 0x3a, 0xc8, 0xb0, - 0xcf, 0x81, 0x8c, 0xa5, 0x36, 0x72, 0x3e, 0x63, - 0x90, 0xd3, 0x08, 0x4e, 0x0e, 0x31, 0xc7, 0x94, - 0x22, 0x29, 0x15, 0x3c, 0xe3, 0x4d, 0x87, 0x39, - 0x29, 0xb1, 0x60, 0x88, 0xd9, 0xe1, 0xaf, 0x43, - 0x21, // OP_DATA_33 valid public key - 0x02, 0xa4, 0xf6, 0x45, 0x86, 0xe1, 0x72, 0xc3, - 0xd9, 0xa2, 0x0c, 0xfa, 0x6c, 0x7a, 0xc8, 0xfb, - 0x12, 0xf0, 0x11, 0x5b, 0x3f, 0x69, 0xc3, 0xc3, - 0x5a, 0xec, 0x93, 0x3a, 0x4c, 0x47, 0xc7, 0xd9, - 0x2c, // No OP_TSPEND - 0xc2, // OP_TSPEND - 0xc2, // Extra OP_TSPEND - } - - // OP_DATA_64 OP_TSPEND OP_DATA_1 - tspendTrailingData = []byte{ - 0x40, // OP_DATA_64 valid signature - 0x77, 0x69, 0x84, 0xf6, 0x83, 0x13, 0xb1, 0xac, - 0x62, 0x9e, 0x62, 0x4a, 0xf0, 0x59, 0x5b, 0xdc, - 0x09, 0xd8, 0xde, 0xd0, 0x2b, 0xc2, 0xb2, 0x9f, - 0xbd, 0xb3, 0x95, 0x95, 0xe0, 0x3a, 0xc8, 0xb0, - 0xcf, 0x81, 0x8c, 0xa5, 0x36, 0x72, 0x3e, 0x63, - 0x90, 0xd3, 0x08, 0x4e, 0x0e, 0x31, 0xc7, 0x94, - 0x22, 0x29, 0x15, 0x3c, 0xe3, 0x4d, 0x87, 0x39, - 0x29, 0xb1, 0x60, 0x88, 0xd9, 0xe1, 0xaf, 0x43, - 0x21, // OP_DATA_33 valid public key - 0x02, 0xa4, 0xf6, 0x45, 0x86, 0xe1, 0x72, 0xc3, - 0xd9, 0xa2, 0x0c, 0xfa, 0x6c, 0x7a, 0xc8, 0xfb, - 0x12, 0xf0, 0x11, 0x5b, 0x3f, 0x69, 0xc3, 0xc3, - 0x5a, 0xec, 0x93, 0x3a, 0x4c, 0x47, 0xc7, 0xd9, - 0x2c, // No OP_TSPEND - 0xc2, // OP_TSPEND - 0x01, // OP_DATA_1, ByteIndex test in CheckTSpend - } ) // opReturnScript returns a provably-pruneable OP_RETURN script with the @@ -380,557 +297,204 @@ func TestTreasuryIsFunctions(t *testing.T) { } } -var tspendTxInNoPubkey = wire.TxIn{ - PreviousOutPoint: wire.OutPoint{ - Hash: chainhash.Hash{}, - Index: 0xffffffff, - Tree: wire.TxTreeRegular, - }, - SignatureScript: []byte{ - 0xc2, // OP_TSPEND - }, - BlockHeight: wire.NullBlockHeight, - BlockIndex: wire.NullBlockIndex, - Sequence: 0xffffffff, -} - -// tspendTxInInvalidPubkey is a TxIn with an invalid key on the OP_TSPEND. -var tspendTxInInvalidPubkey = wire.TxIn{ - PreviousOutPoint: wire.OutPoint{ - Hash: chainhash.Hash{}, - Index: 0xffffffff, - Tree: wire.TxTreeRegular, - }, - SignatureScript: []byte{ - 0xc2, // OP_TSPEND - 0x23, // OP_DATA_35 - 0x03, // Valid pubkey version - 0x00, // invalid compressed key - }, - BlockHeight: wire.NullBlockHeight, - BlockIndex: wire.NullBlockIndex, - Sequence: 0xffffffff, -} - -// tspendTxInInvalidOpcode is a TxIn with an invalid opcode where OP_TSPEND was -// supposed to be. -var tspendTxInInvalidOpcode = wire.TxIn{ - PreviousOutPoint: wire.OutPoint{ - Hash: chainhash.Hash{}, - Index: 0xffffffff, - Tree: wire.TxTreeRegular, - }, - SignatureScript: []byte{ - 0x40, // OP_DATA_64 valid signature - 0x77, 0x69, 0x84, 0xf6, 0x83, 0x13, 0xb1, 0xac, - 0x62, 0x9e, 0x62, 0x4a, 0xf0, 0x59, 0x5b, 0xdc, - 0x09, 0xd8, 0xde, 0xd0, 0x2b, 0xc2, 0xb2, 0x9f, - 0xbd, 0xb3, 0x95, 0x95, 0xe0, 0x3a, 0xc8, 0xb0, - 0xcf, 0x81, 0x8c, 0xa5, 0x36, 0x72, 0x3e, 0x63, - 0x90, 0xd3, 0x08, 0x4e, 0x0e, 0x31, 0xc7, 0x94, - 0x22, 0x29, 0x15, 0x3c, 0xe3, 0x4d, 0x87, 0x39, - 0x29, 0xb1, 0x60, 0x88, 0xd9, 0xe1, 0xaf, 0x43, - 0x21, // OP_DATA_33 valid public key - 0x02, 0xa4, 0xf6, 0x45, 0x86, 0xe1, 0x72, 0xc3, - 0xd9, 0xa2, 0x0c, 0xfa, 0x6c, 0x7a, 0xc8, 0xfb, - 0x12, 0xf0, 0x11, 0x5b, 0x3f, 0x69, 0xc3, 0xc3, - 0x5a, 0xec, 0x93, 0x3a, 0x4c, 0x47, 0xc7, 0xd9, - 0x2c, - 0x6a, // OP_RETURN instead of OP_TSPEND - }, - BlockHeight: wire.NullBlockHeight, - BlockIndex: wire.NullBlockIndex, - Sequence: 0xffffffff, -} - -// tspendTxInInvalidPubkey2 is a TxIn with an invalid public key on the -// OP_TSPEND. -var tspendTxInInvalidPubkey2 = wire.TxIn{ - PreviousOutPoint: wire.OutPoint{ - Hash: chainhash.Hash{}, - Index: 0xffffffff, - Tree: wire.TxTreeRegular, - }, - SignatureScript: []byte{ - 0x40, // OP_DATA_64 valid signature - 0x77, 0x69, 0x84, 0xf6, 0x83, 0x13, 0xb1, 0xac, - 0x62, 0x9e, 0x62, 0x4a, 0xf0, 0x59, 0x5b, 0xdc, - 0x09, 0xd8, 0xde, 0xd0, 0x2b, 0xc2, 0xb2, 0x9f, - 0xbd, 0xb3, 0x95, 0x95, 0xe0, 0x3a, 0xc8, 0xb0, - 0xcf, 0x81, 0x8c, 0xa5, 0x36, 0x72, 0x3e, 0x63, - 0x90, 0xd3, 0x08, 0x4e, 0x0e, 0x31, 0xc7, 0x94, - 0x22, 0x29, 0x15, 0x3c, 0xe3, 0x4d, 0x87, 0x39, - 0x29, 0xb1, 0x60, 0x88, 0xd9, 0xe1, 0xaf, 0x43, - 0x21, // OP_DATA_33 INVALID public key - 0x00, 0xa4, 0xf6, 0x45, 0x86, 0xe1, 0x72, 0xc3, - 0xd9, 0xa2, 0x0c, 0xfa, 0x6c, 0x7a, 0xc8, 0xfb, - 0x12, 0xf0, 0x11, 0x5b, 0x3f, 0x69, 0xc3, 0xc3, - 0x5a, 0xec, 0x93, 0x3a, 0x4c, 0x47, 0xc7, 0xd9, - 0x2c, - 0xc2, // OP_TSPEND - }, - BlockHeight: wire.NullBlockHeight, - BlockIndex: wire.NullBlockIndex, - Sequence: 0xffffffff, -} - -var tspendTxOutValidReturn = wire.TxOut{ - Value: 500000000, - Version: 0, - PkScript: []byte{ - 0x6a, // OP_RETURN - 0x20, // OP_DATA_32 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - }, -} - -var tspendTxOutInvalidReturn = wire.TxOut{ - Value: 500000000, - Version: 0, - PkScript: []byte{ - 0x6a, // OP_RETURN - 0x20, // OP_DATA_32 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 1 byte short - }, -} - -// tspendTxInValidPubkey is a TxIn with a public key on the OP_TSPEND. -var tspendTxInValidPubkey = wire.TxIn{ - PreviousOutPoint: wire.OutPoint{ - Hash: chainhash.Hash{}, - Index: 0xffffffff, - Tree: wire.TxTreeRegular, - }, - SignatureScript: tspendValidKey, - BlockHeight: wire.NullBlockHeight, - BlockIndex: wire.NullBlockIndex, - Sequence: 0xffffffff, -} - -// tspendTxInNoTSpend is a TxIn with a public key but not TSpend opcode. -var tspendTxInNoTSpend = wire.TxIn{ - PreviousOutPoint: wire.OutPoint{ - Hash: chainhash.Hash{}, - Index: 0xffffffff, - Tree: wire.TxTreeRegular, - }, - SignatureScript: tspendNoTSpend, - BlockHeight: wire.NullBlockHeight, - BlockIndex: wire.NullBlockIndex, - Sequence: 0xffffffff, -} - -// tspendTxInTwoTSpend is a TxIn with a public key but two TSpend opcodes. -var tspendTxInTwoTSpend = wire.TxIn{ - PreviousOutPoint: wire.OutPoint{ - Hash: chainhash.Hash{}, - Index: 0xffffffff, - Tree: wire.TxTreeRegular, - }, - SignatureScript: tspendTwoTSpend, - BlockHeight: wire.NullBlockHeight, - BlockIndex: wire.NullBlockIndex, - Sequence: 0xffffffff, -} - -// tspendTxTrailingData is a TxIn with a public key, one TSpend and an -// OP_DATA_1. -var tspendTxTrailingData = wire.TxIn{ - PreviousOutPoint: wire.OutPoint{ - Hash: chainhash.Hash{}, - Index: 0xffffffff, - Tree: wire.TxTreeRegular, - }, - SignatureScript: tspendTrailingData, - BlockHeight: wire.NullBlockHeight, - BlockIndex: wire.NullBlockIndex, - Sequence: 0xffffffff, -} - -// tspendInvalidInCount has an invalid TxIn count but a valid TxOut count. -var tspendInvalidInCount = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{}, - TxOut: []*wire.TxOut{ - {}, // 2 TxOuts is valid - {}, - }, - LockTime: 0, - Expiry: 0, -} - -// tspendInvalidOutCount has a valid TxIn count but an invalid TxOut count. -var tspendInvalidOutCount = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - &tspendTxInNoPubkey, - }, - TxOut: []*wire.TxOut{}, - LockTime: 0, - Expiry: 0, -} - -// tspendInvalidVersion has an invalid version in an out script. -var tspendInvalidVersion = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - &tspendTxInNoPubkey, - }, - TxOut: []*wire.TxOut{ - { - Version: 0, - PkScript: []byte{ - 0x6a, // OP_RETURN - }, - }, - { - Version: 1, // Fail - PkScript: []byte{ - 0xc3, // OP_TGEN - }, - }, - }, - LockTime: 0, - Expiry: 0, -} - -// tspendInvalidSignature has no publick key in the input script. -var tspendInvalidSignature = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - &tspendTxInNoPubkey, - }, - TxOut: []*wire.TxOut{ - { - PkScript: []byte{ - 0x6a, // OP_RETURN - }, - }, - { - PkScript: []byte{ - 0xc3, // OP_TGEN - }, - }, - }, - LockTime: 0, - Expiry: 0, -} - -// tspendInvalidSignature2 has an invalid public key in the input script. -var tspendInvalidSignature2 = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - &tspendTxInInvalidPubkey, - }, - TxOut: []*wire.TxOut{ - { - PkScript: []byte{ - 0x6a, // OP_RETURN - }, - }, - { - PkScript: []byte{ - 0xc3, // OP_TGEN - }, - }, - }, - LockTime: 0, - Expiry: 0, -} - -// tspendInvalidOpcode has an invalid opcode in the first TxIn. -var tspendInvalidOpcode = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - &tspendTxInInvalidOpcode, - }, - TxOut: []*wire.TxOut{ - { - PkScript: []byte{ - 0x6a, // OP_RETURN - }, - }, - { - PkScript: []byte{ - 0xc3, // OP_TGEN - }, - }, - }, - LockTime: 0, - Expiry: 0, -} - -// tspendInvalidPubkey has an invalid public key on the TSPEND. -var tspendInvalidPubkey = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - &tspendTxInInvalidPubkey2, - }, - TxOut: []*wire.TxOut{ - { - PkScript: []byte{ - 0x6a, // OP_RETURN - }, - }, - { - PkScript: []byte{ - 0xc3, // OP_TGEN - }, - }, - }, - LockTime: 0, - Expiry: 0, -} - -// tspendInvalidScriptLength has an invalid TxOut that has a zero length. -var tspendInvalidScriptLength = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - &tspendTxInValidPubkey, - }, - TxOut: []*wire.TxOut{ - &tspendTxOutValidReturn, - {}, - }, - LockTime: 0, - Expiry: 0, -} - -// tspendInvalidTokenCount does not have enough tokens in input script. -var tspendInvalidTokenCount = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - &tspendTxInNoTSpend, - }, - TxOut: []*wire.TxOut{ - &tspendTxOutValidReturn, - { - PkScript: []byte{ - 0xc3, // OP_TGEN - }, - }, - }, - LockTime: 0, - Expiry: 0, -} - -// tspendInvalidTokenCount2 has too many tokens on input script. -var tspendInvalidTokenCount2 = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - &tspendTxInTwoTSpend, - }, - TxOut: []*wire.TxOut{ - &tspendTxOutValidReturn, - { - PkScript: []byte{ - 0xc3, // OP_TGEN - }, - }, - }, - LockTime: 0, - Expiry: 0, -} - -// tspendInvalidTokenCount3 has trailing data after TSpend. -var tspendInvalidTokenCount3 = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - &tspendTxTrailingData, - }, - TxOut: []*wire.TxOut{ - &tspendTxOutValidReturn, - { - PkScript: []byte{ - 0xc3, // OP_TGEN - }, - }, - }, - LockTime: 0, - Expiry: 0, -} - -// tspendInvalidTransaction has an invalid hash on the OP_RETURN. -var tspendInvalidTransaction = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - &tspendTxInValidPubkey, - }, - TxOut: []*wire.TxOut{ - &tspendTxOutInvalidReturn, - { - PkScript: []byte{ - 0xc3, // OP_TGEN - }, - }, - }, - LockTime: 0, - Expiry: 0, -} - -// tspendInvalidTGen has an invalid TxOut that isn't tagged with an OP_TGEN. -var tspendInvalidTGen = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - &tspendTxInValidPubkey, - }, - TxOut: []*wire.TxOut{ - &tspendTxOutValidReturn, - { - PkScript: []byte{ - 0x6a, // OP_RETURN instead of OP_TGEN - }}, - }, - LockTime: 0, - Expiry: 0, -} +// TestTreasurySpendErrors verifies that all check treasury spend errors can be +// hit and return the proper error. +func TestTreasurySpendErrors(t *testing.T) { + tests := []struct { + name string // test description + tx *wire.MsgTx // transaction to test + expected error // expected error + }{{ + name: "treasury spend invalid tx version", + tx: func() *wire.MsgTx { + tx := baseTreasurySpendTx.Copy() + tx.Version = 1 + return tx + }(), + expected: ErrTSpendInvalidTxVersion, + }, { + name: "treasury spend with invalid num inputs", + tx: func() *wire.MsgTx { + tx := baseTreasurySpendTx.Copy() + tx.TxIn = nil + return tx + }(), + expected: ErrTSpendInvalidLength, + }, { + name: "treasury spend with invalid num outputs", + tx: func() *wire.MsgTx { + tx := baseTreasurySpendTx.Copy() + tx.TxOut = nil + return tx + }(), + expected: ErrTSpendInvalidLength, + }, { + name: "treasury spend with an invalid script version", + tx: func() *wire.MsgTx { + tx := baseTreasurySpendTx.Copy() + tx.TxOut[1].Version = 1 + return tx + }(), + expected: ErrTSpendInvalidVersion, + }, { + name: "treasury spend with invalid output - no pubkey script", + tx: func() *wire.MsgTx { + tx := baseTreasurySpendTx.Copy() + tx.TxOut[1].PkScript = nil + return tx + }(), + expected: ErrTSpendInvalidScriptLength, + }, { + name: "treasury spend invalid input sig script - wrong script length", + tx: func() *wire.MsgTx { + sig := treasurySpendSignature(validSignature, nil) + tx := baseTreasurySpendTx.Copy() + tx.TxIn[0].SignatureScript = sig + return tx + }(), + expected: ErrTSpendInvalidScript, + }, { + name: "treasury spend input sig script invalid - wrong sig len", + tx: func() *wire.MsgTx { + tx := baseTreasurySpendTx.Copy() + sig := tx.TxIn[0].SignatureScript + if sig[0] != txscript.OP_DATA_64 { + panic("signature script format changed") + } + sig[0] = txscript.OP_DATA_65 // Wrong length. + return tx + }(), + expected: ErrTSpendInvalidScript, + }, { + name: "treasury spend input sig script invalid - wrong pubkey len", + tx: func() *wire.MsgTx { + tx := baseTreasurySpendTx.Copy() + sig := tx.TxIn[0].SignatureScript + if sig[65] != txscript.OP_DATA_33 { + panic("signature script format changed") + } + sig[65] = txscript.OP_DATA_34 // Wrong length. + return tx + }(), + expected: ErrTSpendInvalidScript, + }, { + name: "treasury spend input sig invalid - wrong opcode for OP_TSPEND", + tx: func() *wire.MsgTx { + tx := baseTreasurySpendTx.Copy() + sig := tx.TxIn[0].SignatureScript + if sig[len(sig)-1] != txscript.OP_TSPEND { + panic("signature script format changed") + } + sig[len(sig)-1] = txscript.OP_RETURN // Wrong opcode. + return tx + }(), + expected: ErrTSpendInvalidScript, + }, { + name: "treasury spend input sig invalid - no tspend opcode", + tx: func() *wire.MsgTx { + tx := baseTreasurySpendTx.Copy() + sig := tx.TxIn[0].SignatureScript + tx.TxIn[0].SignatureScript = sig[:len(sig)-1] + return tx + }(), + expected: ErrTSpendInvalidScript, + }, { + name: "treasury spend input sig invalid - two tspend opcodes", + tx: func() *wire.MsgTx { + tx := baseTreasurySpendTx.Copy() + sig := tx.TxIn[0].SignatureScript + sig = append(sig, txscript.OP_TSPEND) + tx.TxIn[0].SignatureScript = sig + return tx + }(), + expected: ErrTSpendInvalidScript, + }, { + name: "treasury spend input sig invalid - trailing data", + tx: func() *wire.MsgTx { + tx := baseTreasurySpendTx.Copy() + sig := tx.TxIn[0].SignatureScript + sig = append(sig, 0x01) + tx.TxIn[0].SignatureScript = sig + return tx + }(), + expected: ErrTSpendInvalidScript, + }, { + name: "treasury spend input sig script invalid - bad pubkey type", + tx: func() *wire.MsgTx { + pubKey := make([]byte, len(publicKey)) + copy(pubKey, publicKey) + pubKey[0] |= 0x04 + sig := treasurySpendSignature(validSignature, pubKey) -// tspendInvalidP2SH has an invalid TxOut that doesn't have a valid P2SH -// script. -var tspendInvalidP2SH = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - &tspendTxInValidPubkey, - }, - TxOut: []*wire.TxOut{ - &tspendTxOutValidReturn, - { - PkScript: []byte{ - 0xc3, // OP_TGEN - 0x00, // Invalid P2SH - }}, - }, - LockTime: 0, - Expiry: 0, -} + tx := baseTreasurySpendTx.Copy() + tx.TxIn[0].SignatureScript = sig + return tx + }(), + expected: ErrTSpendInvalidPubkey, + }, { + name: "treasury spend invalid - extra empty output", + tx: func() *wire.MsgTx { + tx := baseTreasurySpendTx.Copy() + tx.AddTxOut(&wire.TxOut{}) + return tx + }(), + expected: ErrTSpendInvalidScriptLength, + }, { + name: "treasury spend invalid OP_RETURN output - short one byte", + tx: func() *wire.MsgTx { + tx := baseTreasurySpendTx.Copy() + script := tx.TxOut[0].PkScript + script = script[:len(script)-1] + tx.TxOut[0].PkScript = script + return tx + }(), + expected: ErrTSpendInvalidTransaction, + }, { + name: "treasury spend payment output - wrong opcode for OP_TGEN", + tx: func() *wire.MsgTx { + tx := baseTreasurySpendTx.Copy() + if tx.TxOut[1].PkScript[0] != txscript.OP_TGEN { + panic("payment output format changed") + } + tx.TxOut[1].PkScript[0] = txscript.OP_RETURN + return tx + }(), + expected: ErrTSpendInvalidTGen, + }, { + name: "treasury spend payment output - unsupported p2pk", + tx: func() *wire.MsgTx { + // Start with a normal payment script for the p2pk and manually add + // the OP_TGEN prefix since there is no standard method to create + // the pay from treasury script on a p2pk address given it is + // invalid. + params := chaincfg.RegNetParams() + p2pkAddr, err := stdaddr.NewAddressPubKeyEcdsaSecp256k1V0Raw( + publicKey, params) + if err != nil { + panic(err) + } + payoutScriptVer, payScript := p2pkAddr.PaymentScript() + payoutScript := make([]byte, len(payScript)+1) + payoutScript[0] = txscript.OP_TGEN + copy(payoutScript[1:], payScript) -var tspendInvalidTxVersion = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 1, // Invalid version - TxIn: []*wire.TxIn{ - &tspendTxInValidPubkey, - }, - TxOut: []*wire.TxOut{ - &tspendTxOutValidReturn, - }, - LockTime: 0, - Expiry: 0, -} + tx := baseTreasurySpendTx.Copy() + tx.TxOut[1].Version = payoutScriptVer + tx.TxOut[1].PkScript = payoutScript + return tx + }(), + expected: ErrTSpendInvalidSpendScript, + }} -func TestTSpendErrors(t *testing.T) { - tests := []struct { - name string - tx *wire.MsgTx - expected error - }{ - { - name: "tspendInvalidOutCount", - tx: tspendInvalidOutCount, - expected: ErrTSpendInvalidLength, - }, - { - name: "tspendInvalidInCount", - tx: tspendInvalidInCount, - expected: ErrTSpendInvalidLength, - }, - { - name: "tspendInvalidVersion", - tx: tspendInvalidVersion, - expected: ErrTSpendInvalidVersion, - }, - { - name: "tspendInvalidSignature", - tx: tspendInvalidSignature, - expected: ErrTSpendInvalidScript, - }, - { - name: "tspendInvalidSignature2", - tx: tspendInvalidSignature2, - expected: ErrTSpendInvalidScript, - }, - { - name: "tspendInvalidOpcode", - tx: tspendInvalidOpcode, - expected: ErrTSpendInvalidScript, - }, - { - name: "tspendInvalidPubkey", - tx: tspendInvalidPubkey, - expected: ErrTSpendInvalidPubkey, - }, - { - name: "tspendInvalidTokenCount", - tx: tspendInvalidTokenCount, - expected: ErrTSpendInvalidScript, - }, - { - name: "tspendInvalidTokenCount2", - tx: tspendInvalidTokenCount2, - expected: ErrTSpendInvalidScript, - }, - { - name: "tspendInvalidTokenCount3", - tx: tspendInvalidTokenCount3, - expected: ErrTSpendInvalidScript, - }, - { - name: "tspendInvalidScriptLength", - tx: tspendInvalidScriptLength, - expected: ErrTSpendInvalidScriptLength, - }, - { - name: "tspendInvalidTransaction", - tx: tspendInvalidTransaction, - expected: ErrTSpendInvalidTransaction, - }, - { - name: "tspendInvalidTGen", - tx: tspendInvalidTGen, - expected: ErrTSpendInvalidTGen, - }, - { - name: "tspendInvalidP2SH", - tx: tspendInvalidP2SH, - expected: ErrTSpendInvalidSpendScript, - }, - { - name: "tspendInvalidTxVersion", - tx: tspendInvalidTxVersion, - expected: ErrTSpendInvalidTxVersion, - }, - } - for i, tt := range tests { - test := dcrutil.NewTx(tt.tx) - test.SetTree(wire.TxTreeStake) - test.SetIndex(0) - err := checkTSpend(test.MsgTx()) - if !errors.Is(err, tt.expected) { - t.Errorf("%v: checkTSpend should have returned %v but "+ - "instead returned %v", tt.name, tt.expected, err) + for _, test := range tests { + err := checkTSpend(test.tx) + if !errors.Is(err, test.expected) { + t.Errorf("%q: unexpected error -- got %v, want %v", test.name, err, + test.expected) } - if IsTSpend(test.MsgTx()) { - t.Errorf("IsTSpend claimed an invalid tspend is valid"+ - " %v %v", i, tt.name) + if IsTSpend(test.tx) { + t.Errorf("%q: IsTSpend claimed an invalid treasury spend is valid", + test.name) } } } From 79c0502a729b396ab75d67c2c352271e1a8cf801 Mon Sep 17 00:00:00 2001 From: Dave Collins Date: Wed, 22 Apr 2026 17:48:08 -0500 Subject: [PATCH 05/19] blockchain/stake: Rework treasury add err tests. This reworks the treasury add error tests to use the newly introduced functions that start with a valid treasury add transaction and then mutates a copy to induce the specific error to test. In the process, it also corrects some tests that weren't actually tsting what they claimed. The result is significantly more readable, provides more comprehensive test coverage, is more consistent with the other tests throughout the code base, and reduces the test code for the relevant tests by about 56%. This is part of a larger overall effort to bring the treasury code up to the standards used throughout the rest of the blockchain consensus code. --- blockchain/stake/treasury_test.go | 295 +++++++++--------------------- 1 file changed, 90 insertions(+), 205 deletions(-) diff --git a/blockchain/stake/treasury_test.go b/blockchain/stake/treasury_test.go index 9d1f9f3b7b..609c07022b 100644 --- a/blockchain/stake/treasury_test.go +++ b/blockchain/stake/treasury_test.go @@ -13,7 +13,6 @@ import ( "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" "github.com/decred/dcrd/wire" @@ -499,215 +498,101 @@ func TestTreasurySpendErrors(t *testing.T) { } } -// taddInvalidOutCount has a valid TxIn count but an invalid TxOut count. -var taddInvalidOutCount = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{}, - TxOut: []*wire.TxOut{}, - LockTime: 0, - Expiry: 0, -} - -// taddInvalidOutCount2 has a valid TxIn count but an invalid TxOut count. -var taddInvalidOutCount2 = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - {}, // Valid TxIn count - }, - TxOut: []*wire.TxOut{ - {}, - {}, - {}, - }, - LockTime: 0, - Expiry: 0, -} - -// taddInvalidOutCount3 has a valid TxIn count but an invalid TxIn count. -var taddInvalidOutCount3 = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{}, - TxOut: []*wire.TxOut{ - {}, - {}, - }, - LockTime: 0, - Expiry: 0, -} - -// taddInvalidVersion has an invalid out script version. -var taddInvalidVersion = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - {}, // Empty TxIn - }, - TxOut: []*wire.TxOut{ - {Version: 1}, - {Version: 0}, - }, - LockTime: 0, - Expiry: 0, -} - -// taddInvalidScriptLength has a zero script length. -var taddInvalidScriptLength = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - {}, // Empty TxIn - }, - TxOut: []*wire.TxOut{ - {Version: 0}, - {Version: 0}, - }, - LockTime: 0, - Expiry: 0, -} - -// taddInvalidLength has an invalid out script. -var taddInvalidLength = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - {}, // Empty TxIn - }, - TxOut: []*wire.TxOut{ - {PkScript: []byte{ - 0xc2, // OP_TSPEND instead of OP_TADD - 0x00, // Fail length test - }}, - }, - LockTime: 0, - Expiry: 0, -} - -// taddInvalidLength has an invalid out script opcode. -var taddInvalidOpcode = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - {}, // Empty TxIn - }, - TxOut: []*wire.TxOut{ - { - PkScript: []byte{ - 0xc2, // OP_TSPEND instead of OP_TADD - }, - }, - }, - LockTime: 0, - Expiry: 0, -} - -// taddInvalidChange has an invalid out chnage script. -var taddInvalidChange = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - {}, // Empty TxIn - }, - TxOut: []*wire.TxOut{ - { - PkScript: []byte{ - 0xc1, // OP_TADD - }, - }, - { - PkScript: []byte{ - 0x00, // Not OP_SSTXCHANGE - }, - }, - }, - LockTime: 0, - Expiry: 0, -} - -// taddInvalidTxVersion has an invalid transaction version. -var taddInvalidTxVersion = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 1, // Invalid - TxIn: []*wire.TxIn{}, - TxOut: []*wire.TxOut{ - { - PkScript: []byte{ - 0xc1, // OP_TADD - }, - }, - }, - LockTime: 0, - Expiry: 0, -} - -// TestTAddErrors verifies that all TADD errors can be hit and return the -// proper error. -func TestTAddErrors(t *testing.T) { +// TestTreasuryAddErrors verifies that all check treasury add errors can be hit +// and return the proper error. +func TestTreasuryAddErrors(t *testing.T) { tests := []struct { name string tx *wire.MsgTx expected error - }{ - { - name: "taddInvalidOutCount", - tx: taddInvalidOutCount, - expected: ErrTAddInvalidCount, - }, - { - name: "taddInvalidOutCount2", - tx: taddInvalidOutCount2, - expected: ErrTAddInvalidCount, - }, - { - name: "taddInvalidOutCount3", - tx: taddInvalidOutCount3, - expected: ErrTAddInvalidCount, - }, - { - name: "taddInvalidVersion", - tx: taddInvalidVersion, - expected: ErrTAddInvalidVersion, - }, - { - name: "taddInvalidScriptLength", - tx: taddInvalidScriptLength, - expected: ErrTAddInvalidScriptLength, - }, - { - name: "taddInvalidLength", - tx: taddInvalidLength, - expected: ErrTAddInvalidLength, - }, - { - name: "taddInvalidOpcode", - tx: taddInvalidOpcode, - expected: ErrTAddInvalidOpcode, - }, - { - name: "taddInvalidChange", - tx: taddInvalidChange, - expected: ErrTAddInvalidChange, - }, - { - name: "taddInvalidTxVersion", - tx: taddInvalidTxVersion, - expected: ErrTAddInvalidTxVersion, - }, - } - for i, tt := range tests { - test := dcrutil.NewTx(tt.tx) - test.SetTree(wire.TxTreeStake) - test.SetIndex(0) - err := checkTAdd(test.MsgTx()) - if !errors.Is(err, tt.expected) { - t.Errorf("%v: checkTAdd should have returned %v but "+ - "instead returned %v", tt.name, tt.expected, err) + }{{ + name: "treasury add invalid tx version", + tx: func() *wire.MsgTx { + tx := baseTreasuryAddTx.Copy() + tx.Version = 1 + return tx + }(), + expected: ErrTAddInvalidTxVersion, + }, { + name: "treasury add invalid num outputs - none", + tx: func() *wire.MsgTx { + tx := baseTreasuryAddTx.Copy() + tx.TxOut = nil + return tx + }(), + expected: ErrTAddInvalidCount, + }, { + name: "treasury add invalid num outputs - two change outputs", + tx: func() *wire.MsgTx { + tx := baseTreasuryAddTx.Copy() + tx.AddTxOut(tx.TxOut[1]) + return tx + }(), + expected: ErrTAddInvalidCount, + }, { + name: "treasury add invalid num inputs - none", + tx: func() *wire.MsgTx { + tx := baseTreasuryAddTx.Copy() + tx.TxIn = nil + return tx + }(), + expected: ErrTAddInvalidCount, + }, { + name: "treasury add with invalid output - bad script version", + tx: func() *wire.MsgTx { + tx := baseTreasuryAddTx.Copy() + tx.TxOut[0].Version = 1 + return tx + }(), + expected: ErrTAddInvalidVersion, + }, { + name: "treasury add with invalid output - missing script", + tx: func() *wire.MsgTx { + tx := baseTreasuryAddTx.Copy() + tx.TxOut[0].PkScript = nil + return tx + }(), + expected: ErrTAddInvalidScriptLength, + }, { + name: "treasury add with invalid output - extra trailing byte", + tx: func() *wire.MsgTx { + tx := baseTreasuryAddTx.Copy() + tx.TxOut[0].PkScript = append(tx.TxOut[0].PkScript, txscript.OP_TRUE) + return tx + }(), + expected: ErrTAddInvalidLength, + }, { + name: "treasury add with invalid output - wrong opcode for OP_TADD", + tx: func() *wire.MsgTx { + tx := baseTreasuryAddTx.Copy() + if tx.TxOut[0].PkScript[0] != txscript.OP_TADD { + panic("public key script format changed") + } + tx.TxOut[0].PkScript[0] = txscript.OP_TSPEND + return tx + }(), + expected: ErrTAddInvalidOpcode, + }, { + name: "treasury add with invalid output - wrong opcode for change", + tx: func() *wire.MsgTx { + tx := baseTreasuryAddTx.Copy() + if tx.TxOut[1].PkScript[0] != txscript.OP_SSTXCHANGE { + panic("public key script format changed") + } + tx.TxOut[1].PkScript = tx.TxOut[1].PkScript[1:] + return tx + }(), + expected: ErrTAddInvalidChange, + }} + + for _, test := range tests { + err := checkTAdd(test.tx) + if !errors.Is(err, test.expected) { + t.Errorf("%q: unexpected error -- got %v, want %v", test.name, err, + test.expected) } - if IsTAdd(test.MsgTx()) { - t.Errorf("IsTAdd claimed an invalid tadd is valid"+ - " %v %v", i, tt.name) + if IsTAdd(test.tx) { + t.Errorf("%q: IsTAdd claimed an invalid tadd is valid", test.name) } } } From 37fab816c16921927adc290f172f3137c208beab Mon Sep 17 00:00:00 2001 From: Dave Collins Date: Wed, 22 Apr 2026 17:48:09 -0500 Subject: [PATCH 06/19] blockchain/stake: Rework treasurybase err tests. This reworks the treasurybase error tests to use the newly introduced functions that start with a valid treasurybase and then mutates a copy to induce the specific error to test. In the process, it also corrects some tests that weren't actually tsting what they claimed. The result is significantly more readable, provides more comprehensive test coverage, is more consistent with the other tests throughout the code base, and reduces the test code for the relevant tests by about 63%. This is part of a larger overall effort to bring the treasury code up to the standards used throughout the rest of the blockchain consensus code. --- blockchain/stake/treasury_test.go | 495 ++++++++---------------------- 1 file changed, 132 insertions(+), 363 deletions(-) diff --git a/blockchain/stake/treasury_test.go b/blockchain/stake/treasury_test.go index 609c07022b..525f354994 100644 --- a/blockchain/stake/treasury_test.go +++ b/blockchain/stake/treasury_test.go @@ -7,11 +7,9 @@ package stake import ( "encoding/binary" "errors" - "math" "math/rand" "testing" - "github.com/decred/dcrd/chaincfg/chainhash" "github.com/decred/dcrd/chaincfg/v3" "github.com/decred/dcrd/txscript/v4" "github.com/decred/dcrd/txscript/v4/stdaddr" @@ -597,373 +595,144 @@ func TestTreasuryAddErrors(t *testing.T) { } } -// treasurybaseInvalidInCount has an invalid TxIn count. -var treasurybaseInvalidInCount = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{}, - TxOut: []*wire.TxOut{ - {}, - {}, - }, - LockTime: 0, - Expiry: 0, -} - -// treasurybaseInvalidOutCount has an invalid TxOut count. -var treasurybaseInvalidOutCount = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - {}, - }, - TxOut: []*wire.TxOut{}, - LockTime: 0, - Expiry: 0, -} - -// treasurybaseInvalidVersion has an invalid out script version. -var treasurybaseInvalidVersion = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - {}, - }, - TxOut: []*wire.TxOut{ - {Version: 0}, - {Version: 2}, - }, - LockTime: 0, - Expiry: 0, -} - -// treasurybaseInvalidOpcode0 has an invalid out script opcode. -var treasurybaseInvalidOpcode0 = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - {}, - }, - TxOut: []*wire.TxOut{ - { - PkScript: []byte{ - 0xc2, // OP_TSPEND instead of OP_TADD - }, - }, - { - PkScript: []byte{ - 0x6a, // OP_RETURN - 0x0c, // OP_DATA_12 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - }, - }, - }, - LockTime: 0, - Expiry: 0, -} - -// treasurybaseInvalidOpcode0Len has an invalid out script opcode length. -var treasurybaseInvalidOpcode0Len = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - {}, - }, - TxOut: []*wire.TxOut{ - { - PkScript: nil, // Invalid - }, - { - PkScript: []byte{ - 0x6a, // OP_RETURN - 0x0c, // OP_DATA_12 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - }, - }, - }, - LockTime: 0, - Expiry: 0, -} - -// treasurybaseInvalidOpcode1 has an invalid out script opcode. -var treasurybaseInvalidOpcode1 = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - {}, - }, - TxOut: []*wire.TxOut{ - { - PkScript: []byte{ - 0xc1, // OP_TADD - }, - }, - { - PkScript: []byte{ - 0xc1, // OP_TADD instead of OP_RETURN - 0x0c, // OP_DATA_32 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - }, - }, - }, - LockTime: 0, - Expiry: 0, -} - -// treasurybaseInvalidOpcode1Len has an invalid out script opcode length. -var treasurybaseInvalidOpcode1Len = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - {}, - }, - TxOut: []*wire.TxOut{ - { - PkScript: []byte{ - 0xc1, // OP_TADD - }, - }, - { - PkScript: nil, - }, - }, - LockTime: 0, - Expiry: 0, -} - -// treasurybaseInvalidOpcodeDataPush has an invalid out script data push in -// script 1 opcode 1. -var treasurybaseInvalidOpcodeDataPush = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - {}, - }, - TxOut: []*wire.TxOut{ - { - PkScript: []byte{ - 0xc1, // OP_TADD - }, - }, - { - PkScript: []byte{ - 0x6a, // OP_RETURN - 0x05, // OP_DATA_5 instead of OP_DATA_4 - 0x00, 0x00, 0x00, 0x00, 0x00, - }, - }, - }, - LockTime: 0, - Expiry: 0, -} - -// treasurybaseInvalid has invalid in script constants. -var treasurybaseInvalid = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - { - PreviousOutPoint: wire.OutPoint{ - Index: math.MaxUint32 - 1, - }, - }, - }, - TxOut: []*wire.TxOut{ - { - PkScript: []byte{ - 0xc1, // OP_TADD - }, - }, - { - PkScript: []byte{ - 0x6a, // OP_RETURN - 0x0c, // OP_DATA_12 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - }, - }, - }, - LockTime: 0, - Expiry: 0, -} - -// treasurybaseInvalid2 has invalid in script constants. -var treasurybaseInvalid2 = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - { - PreviousOutPoint: wire.OutPoint{ - Index: math.MaxUint32, - Hash: chainhash.Hash{'m', 'o', 'o'}, - }, - }, - }, - TxOut: []*wire.TxOut{ - { - PkScript: []byte{ - 0xc1, // OP_TADD - }, - }, - { - PkScript: []byte{ - 0x6a, // OP_RETURN - 0x0c, // OP_DATA_12 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - }, - }, - }, - LockTime: 0, - Expiry: 0, -} - -// treasurybaseInvalidTxVersion has an invalid transaction version. -var treasurybaseInvalidTxVersion = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 1, // Invalid - TxIn: []*wire.TxIn{ - { - PreviousOutPoint: wire.OutPoint{ - Index: math.MaxUint32, - Hash: chainhash.Hash{'m', 'o', 'o'}, - }, - }, - }, - TxOut: []*wire.TxOut{ - { - PkScript: []byte{ - 0xc1, // OP_TADD - }, - }, - { - PkScript: []byte{ - 0x6a, // OP_RETURN - 0x0c, // OP_DATA_12 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - }, - }, - }, - LockTime: 0, - Expiry: 0, -} - -// treasurybaseInvalidLength has an invalid transaction length. -var treasurybaseInvalidLength = &wire.MsgTx{ - SerType: wire.TxSerializeFull, - Version: 3, - TxIn: []*wire.TxIn{ - { - PreviousOutPoint: wire.OutPoint{ - Index: math.MaxUint32, - Hash: chainhash.Hash{'m', 'o', 'o'}, - }, - SignatureScript: []byte{0x00}, - }, - }, - TxOut: []*wire.TxOut{ - { - PkScript: []byte{ - 0xc1, // OP_TADD - }, - }, - { - PkScript: []byte{ - 0x6a, // OP_RETURN - 0x04, // OP_DATA_4 - 0x00, 0x00, 0x00, 0x00, - }, - }, - }, - LockTime: 0, - Expiry: 0, -} - -// TestTreasuryBaseErrors verifies that all treasurybase errors can be hit and -// return the proper error. +// TestTreasuryBaseErrors verifies that all check treasurybase errors can be hit +// and return the proper error. func TestTreasuryBaseErrors(t *testing.T) { tests := []struct { name string tx *wire.MsgTx expected error - }{ - { - name: "treasurybaseInvalidInCount", - tx: treasurybaseInvalidInCount, - expected: ErrTreasuryBaseInvalidCount, - }, - { - name: "treasurybaseInvalidOutCount", - tx: treasurybaseInvalidOutCount, - expected: ErrTreasuryBaseInvalidCount, - }, - { - name: "treasurybaseInvalidVersion", - tx: treasurybaseInvalidVersion, - expected: ErrTreasuryBaseInvalidVersion, - }, - { - name: "treasurybaseInvalidOpcode0", - tx: treasurybaseInvalidOpcode0, - expected: ErrTreasuryBaseInvalidOpcode0, - }, - { - name: "treasurybaseInvalidOpcode0Len", - tx: treasurybaseInvalidOpcode0Len, - expected: ErrTreasuryBaseInvalidOpcode0, - }, - { - name: "treasurybaseInvalidOpcode1", - tx: treasurybaseInvalidOpcode1, - expected: ErrTreasuryBaseInvalidOpcode1, - }, - { - name: "treasurybaseInvalidOpcode1Len", - tx: treasurybaseInvalidOpcode1Len, - expected: ErrTreasuryBaseInvalidOpcode1, - }, - { - name: "treasurybaseInvalidDataPush", - tx: treasurybaseInvalidOpcodeDataPush, - expected: ErrTreasuryBaseInvalidOpcode1, - }, - { - name: "treasurybaseInvalid", - tx: treasurybaseInvalid, - expected: ErrTreasuryBaseInvalid, - }, - { - name: "treasurybaseInvalid2", - tx: treasurybaseInvalid2, - expected: ErrTreasuryBaseInvalid, - }, - { - name: "treasurybaseInvalidTxVersion", - tx: treasurybaseInvalidTxVersion, - expected: ErrTreasuryBaseInvalidTxVersion, - }, - { - name: "treasurybaseInvalidLength", - tx: treasurybaseInvalidLength, - expected: ErrTreasuryBaseInvalidLength, - }, - } - for i, tt := range tests { - test := dcrutil.NewTx(tt.tx) - test.SetTree(wire.TxTreeStake) - test.SetIndex(0) - err := checkTreasuryBase(test.MsgTx()) - if !errors.Is(err, tt.expected) { - t.Errorf("%v: checkTreasuryBase should have returned "+ - "%v but instead returned %v", tt.name, tt.expected, err) + }{{ + name: "treasurybase invalid tx version", + tx: func() *wire.MsgTx { + tx := baseTreasuryBaseTx.Copy() + tx.Version = 1 + return tx + }(), + expected: ErrTreasuryBaseInvalidTxVersion, + }, { + name: "treasurybase invalid num inputs - none", + tx: func() *wire.MsgTx { + tx := baseTreasuryBaseTx.Copy() + tx.TxIn = nil + return tx + }(), + expected: ErrTreasuryBaseInvalidCount, + }, { + name: "treasurybase invalid num outputs - none", + tx: func() *wire.MsgTx { + tx := baseTreasuryBaseTx.Copy() + tx.TxOut = nil + return tx + }(), + expected: ErrTreasuryBaseInvalidCount, + }, { + name: "treasurybase invalid num outputs - extra outupt", + tx: func() *wire.MsgTx { + tx := baseTreasuryBaseTx.Copy() + tx.TxOut = append(tx.TxOut, newTxOut(1, 0, opTrueScript)) + return tx + }(), + expected: ErrTreasuryBaseInvalidCount, + }, { + name: "treasurybase invalid input 0 - non-empty signature script", + tx: func() *wire.MsgTx { + tx := baseTreasuryBaseTx.Copy() + tx.TxIn[0].SignatureScript = []byte{txscript.OP_TRUE} + return tx + }(), + expected: ErrTreasuryBaseInvalidLength, + }, { + name: "treasurybase invalid output - bad script version", + tx: func() *wire.MsgTx { + tx := baseTreasuryBaseTx.Copy() + tx.TxOut[1].Version = 2 + return tx + }(), + expected: ErrTreasuryBaseInvalidVersion, + }, { + name: "treasurybase invalid output 0 - wrong opcode for OP_TADD", + tx: func() *wire.MsgTx { + tx := baseTreasuryBaseTx.Copy() + if tx.TxOut[0].PkScript[0] != txscript.OP_TADD { + panic("public key script format changed") + } + tx.TxOut[0].PkScript[0] = txscript.OP_TSPEND + return tx + }(), + expected: ErrTreasuryBaseInvalidOpcode0, + }, { + name: "treasurybase invalid output 0 - extra trailing byte", + tx: func() *wire.MsgTx { + tx := baseTreasuryBaseTx.Copy() + tx.TxOut[0].PkScript = append(tx.TxOut[0].PkScript, txscript.OP_TRUE) + return tx + }(), + expected: ErrTreasuryBaseInvalidOpcode0, + }, { + name: "treasurybase invalid output 1 - wrong opcode for OP_RETURN", + tx: func() *wire.MsgTx { + tx := baseTreasuryBaseTx.Copy() + if tx.TxOut[1].PkScript[0] != txscript.OP_RETURN { + panic("public key script format changed") + } + tx.TxOut[1].PkScript[0] = txscript.OP_TADD + return tx + }(), + expected: ErrTreasuryBaseInvalidOpcode1, + }, { + name: "treasurybase invalid output 1 - extra trailing byte", + tx: func() *wire.MsgTx { + tx := baseTreasuryBaseTx.Copy() + tx.TxOut[1].PkScript = append(tx.TxOut[1].PkScript, txscript.OP_TRUE) + return tx + }(), + expected: ErrTreasuryBaseInvalidOpcode1, + }, { + name: "treasurybase invalid output 1 - wrong data push size", + tx: func() *wire.MsgTx { + tx := baseTreasuryBaseTx.Copy() + if tx.TxOut[1].PkScript[1] != txscript.OP_DATA_12 { + panic("public key script format changed") + } + tx.TxOut[1].PkScript[1] = txscript.OP_DATA_11 + return tx + }(), + expected: ErrTreasuryBaseInvalidOpcode1, + }, { + name: "treasurybase invalid input 0 - non-null hash", + tx: func() *wire.MsgTx { + tx := baseTreasuryBaseTx.Copy() + tx.TxIn[0].PreviousOutPoint.Hash[0] = 0x01 + return tx + }(), + expected: ErrTreasuryBaseInvalid, + }, { + name: "treasurybase invalid input 0 - wrong prev index", + tx: func() *wire.MsgTx { + tx := baseTreasuryBaseTx.Copy() + tx.TxIn[0].PreviousOutPoint.Index = 1 + return tx + }(), + expected: ErrTreasuryBaseInvalid, + }, { + name: "treasurybase invalid input 0 - wrong prev tree", + tx: func() *wire.MsgTx { + tx := baseTreasuryBaseTx.Copy() + tx.TxIn[0].PreviousOutPoint.Tree = wire.TxTreeStake + return tx + }(), + expected: ErrTreasuryBaseInvalid, + }} + for _, test := range tests { + err := checkTreasuryBase(test.tx) + if !errors.Is(err, test.expected) { + t.Errorf("%q: unexpected error -- got %v, want %v", test.name, err, + test.expected) } - if IsTreasuryBase(test.MsgTx()) { - t.Errorf("IsTreasuryBase claimed an invalid treasury "+ - "base is valid %v %v", i, tt.name) + if IsTreasuryBase(test.tx) { + t.Errorf("%q: IsTreasuryBase claimed an invalid treasury base is "+ + "valid", test.name) } } } From 0b5c45fb09df17372780aba526999d3599400a03 Mon Sep 17 00:00:00 2001 From: Dave Collins Date: Wed, 22 Apr 2026 17:48:09 -0500 Subject: [PATCH 07/19] blockchain/stake: Cleanup treasury add code. This cleans up the CheckTAdd method to make it much more consistent with the other code used in consensus throughout the rest of the code base. While there are no known exploitable issues with the func and it has worked well for a while now, it is highly inconsistent with the rest of the consensus code in style and polish and has various other issues. For example: - several of the reported error message are incorrect - most of the error message don't provide very helpful messages and reference internal names that are not visible to users - inconsistent variable names - uses less efficient inverted logic tests - various misleading and inaccurate comments - exported func comment refers to internal func that is not visible in generated documention This is part of a larger overall effort to bring the treasury code up to the standards used throughout the rest of the blockchain consensus code. --- blockchain/stake/treasury.go | 114 +++++++++++++++++------------- blockchain/stake/treasury_test.go | 2 +- 2 files changed, 66 insertions(+), 50 deletions(-) diff --git a/blockchain/stake/treasury.go b/blockchain/stake/treasury.go index e78b06ee46..18d3ff4760 100644 --- a/blockchain/stake/treasury.go +++ b/blockchain/stake/treasury.go @@ -20,11 +20,12 @@ const ( TSpendScriptLen = 100 ) -// This file contains the functions that verify that treasury transactions -// strictly adhere to the specified format. +// ----------------------------------------------------------------------------- +// This file contains functions that verify that treasury transactions strictly +// adhere to the specified format. // // == User sends to treasury == -// TxIn: Normal TxIn signature scripts +// TxIn: Normal TxIn signature scripts // TxOut[0] OP_TADD // TxOut[1] optional OP_SSTXCHANGE // @@ -37,73 +38,88 @@ const ( // TxIn[0] OP_TSPEND // TxOut[0] OP_RETURN // TxOut[1..N] OP_TGEN +// ----------------------------------------------------------------------------- -// checkTAdd verifies that the provided MsgTx is a valid TADD. -// Note: this function does not recognize treasurybase TADDs. -func checkTAdd(mtx *wire.MsgTx) error { - // Require version TxVersionTreasury. - if mtx.Version != wire.TxVersionTreasury { - return stakeRuleError(ErrTAddInvalidTxVersion, - fmt.Sprintf("invalid TADD script version: %v", - mtx.Version)) +// CheckTAdd verifies that the provided transaction satisfies the structural +// requirements to be a valid treasury add transaction. A treasury add +// transaction is one that sends existing funds to the decentralized treasury. +// +// A valid treasury add must have: +// - The transaction version set to [wire.TxVersionTreasury] +// - One or more normal inputs referencing the coins to spend +// - An output with a treasury add script (OP_TADD) +// - An optional second output that must be a stake change script +// (OP_SSTXCHANGE) when present +// - All script versions set to 0 +func CheckTAdd(tx *wire.MsgTx) error { + // The transaction version must be the required treasury version. + if tx.Version != wire.TxVersionTreasury { + str := fmt.Sprintf("treasury add transaction version is %d instead of %d", + tx.Version, wire.TxVersionTreasury) + return stakeRuleError(ErrTAddInvalidTxVersion, str) } - // A TADD consists of one OP_TADD in PkScript[0] followed by 0 or 1 - // stake change outputs. It also requires at least one input. - if !(len(mtx.TxOut) == 1 || len(mtx.TxOut) == 2) || len(mtx.TxIn) < 1 { - return stakeRuleError(ErrTAddInvalidCount, - fmt.Sprintf("invalid TADD script: TxIn %v TxOut %v", - len(mtx.TxIn), len(mtx.TxOut))) + // A treasury add must have at least one input and one or two outputs. + if len(tx.TxIn) < 1 { + const str = "treasury add transaction does not have any inputs" + return stakeRuleError(ErrTAddInvalidCount, str) + } + if len(tx.TxOut) != 1 && len(tx.TxOut) != 2 { + str := fmt.Sprintf("treasury add transaction has %d outputs instead "+ + "of 1 or 2", len(tx.TxOut)) + return stakeRuleError(ErrTAddInvalidCount, str) } // All output scripts must be version 0 and non-empty. const consensusScriptVer = 0 - for k := range mtx.TxOut { - if mtx.TxOut[k].Version != consensusScriptVer { - return stakeRuleError(ErrTAddInvalidVersion, - fmt.Sprintf("invalid script version found "+ - "in TADD TxOut: %v", k)) + for txOutIdx := range tx.TxOut { + txOut := tx.TxOut[txOutIdx] + if txOut.Version != consensusScriptVer { + str := fmt.Sprintf("treasury add transaction output %d script "+ + "version is %d instead of %d", txOutIdx, txOut.Version, + consensusScriptVer) + return stakeRuleError(ErrTAddInvalidVersion, str) } - - if len(mtx.TxOut[k].PkScript) == 0 { - return stakeRuleError(ErrTAddInvalidScriptLength, - fmt.Sprintf("zero script length found in "+ - "TADD: %v", k)) + if len(txOut.PkScript) == 0 { + str := fmt.Sprintf("treasury add transaction output %d script is "+ + "empty", txOutIdx) + return stakeRuleError(ErrTAddInvalidScriptLength, str) } } - // First output must be a TADD - if len(mtx.TxOut[0].PkScript) != 1 { - return stakeRuleError(ErrTAddInvalidLength, - fmt.Sprintf("TADD script length is not 1 byte, got %v", - len(mtx.TxOut[0].PkScript))) + // The first output must be a script that only consists of OP_TADD. + firstTxOut := tx.TxOut[0] + if len(firstTxOut.PkScript) != 1 { + str := fmt.Sprintf("treasury add transaction output 0 script length "+ + "is %d bytes instead of 1 byte", len(firstTxOut.PkScript)) + return stakeRuleError(ErrTAddInvalidLength, str) } - if mtx.TxOut[0].PkScript[0] != txscript.OP_TADD { - return stakeRuleError(ErrTAddInvalidOpcode, - fmt.Sprintf("first output must be a TADD, got 0x%x", - mtx.TxOut[0].PkScript[0])) + if firstTxOut.PkScript[0] != txscript.OP_TADD { + str := fmt.Sprintf("treasury add transaction output 0 script is 0x%x "+ + "instead of OP_TADD (0x%x)", firstTxOut.PkScript[0], + txscript.OP_TADD) + return stakeRuleError(ErrTAddInvalidOpcode, str) } - // Only 1 stake change output allowed. - if len(mtx.TxOut) == 2 { - // Script length has been already verified. - if !IsStakeChangeScript(mtx.TxOut[1].Version, mtx.TxOut[1].PkScript) { - return stakeRuleError(ErrTAddInvalidChange, - "second output must be an OP_SSTXCHANGE script") + // The second output must be a valid stake change output when present. + if len(tx.TxOut) == 2 { + changeTxOut := tx.TxOut[1] + if !IsStakeChangeScript(changeTxOut.Version, changeTxOut.PkScript) { + const str = "treasury add transaction output 1 is not a " + + "stake change script" + return stakeRuleError(ErrTAddInvalidChange, str) } } return nil } -// CheckTAdd exports checkTAdd for testing purposes. -func CheckTAdd(mtx *wire.MsgTx) error { - return checkTAdd(mtx) -} - -// IsTAdd returns true if the provided transaction is a proper TADD. +// IsTAdd returns whether or not the provided transaction satisfies the +// structural requirements to be a valid treasury add transaction. +// +// See the [CheckTAdd] documentation for more details. func IsTAdd(tx *wire.MsgTx) bool { - return checkTAdd(tx) == nil + return CheckTAdd(tx) == nil } // CheckTSpend verifies if a MsgTx is a valid TSPEND. diff --git a/blockchain/stake/treasury_test.go b/blockchain/stake/treasury_test.go index 525f354994..14ec3e0e77 100644 --- a/blockchain/stake/treasury_test.go +++ b/blockchain/stake/treasury_test.go @@ -584,7 +584,7 @@ func TestTreasuryAddErrors(t *testing.T) { }} for _, test := range tests { - err := checkTAdd(test.tx) + err := CheckTAdd(test.tx) if !errors.Is(err, test.expected) { t.Errorf("%q: unexpected error -- got %v, want %v", test.name, err, test.expected) From da5f2a2b477a504dcb516d1263fe53d88eec6f2c Mon Sep 17 00:00:00 2001 From: Dave Collins Date: Wed, 22 Apr 2026 17:48:10 -0500 Subject: [PATCH 08/19] blockchain/stake: Cleanup treasury spend code. This cleans up the CheckTSpend method to make it much more consistent with the other code used in consensus throughout the rest of the code base. While there are no known exploitable issues with the func and it has worked well for a while now, it is highly inconsistent with the rest of the consensus code in style and polish and has various other issues. For example: - several of the reported error message are incorrect - most of the error message don't provide very helpful messages and reference internal names that are not visible to users - inconsistent errors - inconsistent variable names - uses less efficient and harder to read inverted logic tests - various misleading and inaccurate comments This is part of a larger overall effort to bring the treasury code up to the standards used throughout the rest of the blockchain consensus code. --- blockchain/stake/treasury.go | 145 ++++++++++++++++-------------- blockchain/stake/treasury_test.go | 2 +- 2 files changed, 79 insertions(+), 68 deletions(-) diff --git a/blockchain/stake/treasury.go b/blockchain/stake/treasury.go index 18d3ff4760..948e1c1902 100644 --- a/blockchain/stake/treasury.go +++ b/blockchain/stake/treasury.go @@ -36,7 +36,7 @@ const ( // // == Spend from treasury == // TxIn[0] OP_TSPEND -// TxOut[0] OP_RETURN +// TxOut[0] OP_RETURN OP_DATA_32 [8]{LE encoded input value} [24]{random} // TxOut[1..N] OP_TGEN // ----------------------------------------------------------------------------- @@ -122,101 +122,112 @@ func IsTAdd(tx *wire.MsgTx) bool { return CheckTAdd(tx) == nil } -// CheckTSpend verifies if a MsgTx is a valid TSPEND. +// CheckTSpend verifies that the provided transaction satisfies the structural +// requirements to be a valid treasury spend transaction. It returns the +// signature and public key encoded in the first input when the error is nil. +// // This function DOES NOT check the signature or if the public key is a well -// known PI key. This is a convenience function to obtain the signature and -// public key without iterating over the same MsgTx over and over again. The -// return values are signature, public key and an error. -func CheckTSpend(mtx *wire.MsgTx) ([]byte, []byte, error) { - // Require version TxVersionTreasury. - if mtx.Version != wire.TxVersionTreasury { - return nil, nil, stakeRuleError(ErrTSpendInvalidTxVersion, - fmt.Sprintf("invalid TSpend script version: %v", - mtx.Version)) +// known PI key. It also DOES NOT check the input value encoded in the data +// push of the first output matches the input value. +// +// A valid treasury spend must have: +// - The transaction version set to [wire.TxVersionTreasury] +// - A single input with a treasury spend script ( OP_TSPEND) +// - The first output with a 32 byte nulldata script +// (<8-byte LE encoded input value + 24-byte random>) +// - One or more remaining outputs that must be treasury gen scripts (OP_TGEN +// followed by pay-to-pubkey-hash or pay-to-script-hash) +// - All script versions set to 0 +func CheckTSpend(tx *wire.MsgTx) ([]byte, []byte, error) { + // The transaction version must be the required treasury version. + if tx.Version != wire.TxVersionTreasury { + str := fmt.Sprintf("treasury spend transaction version is %d instead "+ + "of %d", tx.Version, wire.TxVersionTreasury) + return nil, nil, stakeRuleError(ErrTSpendInvalidTxVersion, str) } - // A valid TSPEND consists of a single TxIn that contains a signature, - // a public key and an OP_TSPEND opcode. - // - // There must be at least two outputs. The first must contain an - // OP_RETURN followed by a 32 byte data push of a random number. This - // is used to randomize the transaction hash. - // The second output must be a TGEN tagged P2SH or P2PKH script. - if len(mtx.TxIn) != 1 || len(mtx.TxOut) < 2 { - return nil, nil, stakeRuleError(ErrTSpendInvalidLength, - fmt.Sprintf("invalid TSPEND script lengths in: %v "+ - "out: %v", len(mtx.TxIn), len(mtx.TxOut))) + // A treasury spend must have exactly one input and at least two outputs. + if len(tx.TxIn) != 1 { + str := fmt.Sprintf("treasury spend transaction has %d inputs instead "+ + "of 1", len(tx.TxIn)) + return nil, nil, stakeRuleError(ErrTSpendInvalidLength, str) + } + if len(tx.TxOut) < 2 { + str := fmt.Sprintf("treasury spend transaction does not have enough "+ + "outputs (min: %d, have: %d)", 2, len(tx.TxOut)) + return nil, nil, stakeRuleError(ErrTSpendInvalidLength, str) } // All output scripts must be version 0 and non-empty. const consensusScriptVer = 0 - for k, txOut := range mtx.TxOut { + for txOutIdx, txOut := range tx.TxOut { if txOut.Version != consensusScriptVer { - return nil, nil, stakeRuleError(ErrTSpendInvalidVersion, - fmt.Sprintf("invalid script version found in "+ - "TxOut: %v", k)) + str := fmt.Sprintf("treasury spend transaction output %d script "+ + "version is %d instead of %d", txOutIdx, txOut.Version, + consensusScriptVer) + return nil, nil, stakeRuleError(ErrTSpendInvalidVersion, str) } if len(txOut.PkScript) == 0 { - return nil, nil, stakeRuleError(ErrTSpendInvalidScriptLength, - fmt.Sprintf("invalid TxOut script length %v: "+ - "%v", k, len(txOut.PkScript))) + str := fmt.Sprintf("treasury spend transaction output %d script "+ + "is empty", txOutIdx) + return nil, nil, stakeRuleError(ErrTSpendInvalidScriptLength, str) } } - txIn := mtx.TxIn[0].SignatureScript - if !(len(txIn) == TSpendScriptLen && - txIn[0] == txscript.OP_DATA_64 && - txIn[65] == txscript.OP_DATA_33 && - txIn[99] == txscript.OP_TSPEND) { - return nil, nil, stakeRuleError(ErrTSpendInvalidScript, - "TSPEND invalid tspend script") - } + // The single input must have the exact treasury spend script format: + // + // DATA_64 <64-byte schnorr signature> DATA_33 <33-byte pubkey> OP_TSPEND + txIn := tx.TxIn[0].SignatureScript + if len(txIn) != TSpendScriptLen || txIn[0] != txscript.OP_DATA_64 || + txIn[65] != txscript.OP_DATA_33 || txIn[99] != txscript.OP_TSPEND { - // Pull out signature, pubkey. + const str = "treasury spend transaction input 0 script is malformed" + return nil, nil, stakeRuleError(ErrTSpendInvalidScript, str) + } signature := txIn[1 : 1+schnorr.SignatureSize] pubKey := txIn[66 : 66+secp256k1.PubKeyBytesLenCompressed] + + // The public key must adhere to the strict compressed public key encoding. if !txscript.IsStrictCompressedPubKeyEncoding(pubKey) { - return nil, nil, stakeRuleError(ErrTSpendInvalidPubkey, - "TSPEND invalid public key") + str := fmt.Sprintf("treasury spend transaction input 0 public key %x "+ + "does not use strict compressed encoding", pubKey) + return nil, nil, stakeRuleError(ErrTSpendInvalidPubkey, str) } - // Make sure TxOut[0] contains an OP_RETURN followed by a 32 byte data - // push. - if !txscript.IsStrictNullData(mtx.TxOut[0].Version, - mtx.TxOut[0].PkScript, 32) { - return nil, nil, stakeRuleError(ErrTSpendInvalidTransaction, - "First TSPEND output should have been an OP_RETURN "+ - "followed by a 32 byte data push") + // The first output must be an OP_RETURN followed by a 32 byte data push. + firstTxOut := tx.TxOut[0] + if !txscript.IsStrictNullData(firstTxOut.Version, firstTxOut.PkScript, 32) { + const str = "treasury spend transaction output 0 script is not an " + + "OP_RETURN followed by a 32 byte data push" + return nil, nil, stakeRuleError(ErrTSpendInvalidTransaction, str) } - // Verify that the TxOut's contains a P2PKH or P2PKH scripts. - for k, txOut := range mtx.TxOut[1:] { - // All tx outs are tagged with OP_TGEN - if txOut.PkScript[0] != txscript.OP_TGEN { - return nil, nil, stakeRuleError(ErrTSpendInvalidTGen, - fmt.Sprintf("Output %v is not tagged with "+ - "OP_TGEN", k+1)) + // All outputs after the first one must have OP_TGEN tagged p2pkh or p2sh + // scripts. + for txOutIdx, txOut := range tx.TxOut[1:] { + script := txOut.PkScript + if script[0] != txscript.OP_TGEN { + str := fmt.Sprintf("treasury spend transaction output %d script "+ + "is not tagged with OP_TGEN", txOutIdx+1) + return nil, nil, stakeRuleError(ErrTSpendInvalidTGen, str) } - if !(isPubKeyHashScript(txOut.PkScript[1:]) || - isScriptHashScript(txOut.PkScript[1:])) { - - return nil, nil, stakeRuleError(ErrTSpendInvalidSpendScript, - fmt.Sprintf("Output %v is not P2SH or P2PKH", k+1)) + if !isPubKeyHashScript(script[1:]) && !isScriptHashScript(script[1:]) { + str := fmt.Sprintf("treasury spend transaction output %d script "+ + "is not pay-to-script-hash or pay-to-pubkey-hash", txOutIdx+1) + return nil, nil, stakeRuleError(ErrTSpendInvalidSpendScript, str) } } return signature, pubKey, nil } -// checkTSpend verifies if a MsgTx is a valid TSPEND. -func checkTSpend(mtx *wire.MsgTx) error { - _, _, err := CheckTSpend(mtx) - return err -} - -// IsTSpend returns true if the provided transaction is a proper TSPEND. +// IsTSpend returns whether or not the provided transaction satisfies the +// structural requirements to be a valid treasury spend transaction. +// +// See the [CheckTSpend] documentation for more details. func IsTSpend(tx *wire.MsgTx) bool { - return checkTSpend(tx) == nil + _, _, err := CheckTSpend(tx) + return err == nil } // checkTreasuryBase verifies that the provided MsgTx is a treasury base. diff --git a/blockchain/stake/treasury_test.go b/blockchain/stake/treasury_test.go index 14ec3e0e77..034698960e 100644 --- a/blockchain/stake/treasury_test.go +++ b/blockchain/stake/treasury_test.go @@ -484,7 +484,7 @@ func TestTreasurySpendErrors(t *testing.T) { }} for _, test := range tests { - err := checkTSpend(test.tx) + _, _, err := CheckTSpend(test.tx) if !errors.Is(err, test.expected) { t.Errorf("%q: unexpected error -- got %v, want %v", test.name, err, test.expected) From 7cd4cbe1c112b2862deff6298bcbd6ce362d4188 Mon Sep 17 00:00:00 2001 From: Dave Collins Date: Wed, 22 Apr 2026 17:48:11 -0500 Subject: [PATCH 09/19] blockchain/stake: Cleanup treasurybase code. This cleans up the CheckTreasuryBase method to make it much more consistent with the other code used in consensus throughout the rest of the code base. While there are no known exploitable issues with the func and it has worked well for a while now, it is highly inconsistent with the rest of the consensus code in style and polish and has various other issues. For example: - several of the reported error message are incorrect - most of the error message don't provide very helpful messages and reference internal names that are not visible to users - inconsistent variable names - some checks are not in the most logical order - various misleading and inaccurate comments - some checks are not making use of existing funcs This is part of a larger overall effort to bring the treasury code up to the standards used throughout the rest of the blockchain consensus code. --- blockchain/stake/treasury.go | 115 +++++++++++++++++------------- blockchain/stake/treasury_test.go | 2 +- 2 files changed, 67 insertions(+), 50 deletions(-) diff --git a/blockchain/stake/treasury.go b/blockchain/stake/treasury.go index 948e1c1902..8bd029445c 100644 --- a/blockchain/stake/treasury.go +++ b/blockchain/stake/treasury.go @@ -230,73 +230,90 @@ func IsTSpend(tx *wire.MsgTx) bool { return err == nil } -// checkTreasuryBase verifies that the provided MsgTx is a treasury base. -func checkTreasuryBase(mtx *wire.MsgTx) error { - // Require version TxVersionTreasury. - if mtx.Version != wire.TxVersionTreasury { - return stakeRuleError(ErrTreasuryBaseInvalidTxVersion, - fmt.Sprintf("invalid treasurybase script version: %v", - mtx.Version)) +// CheckTreasuryBase verifies that the provided transaction satisfies the +// structural requirements to be a valid treasurybase transaction. +// +// A valid treasurybase must have: +// - The transaction version set to [wire.TxVersionTreasury] +// - A single treasurybase input (no signature script, null prevout) +// - An output with a treasury add script (OP_TADD) +// - An output with a 12 byte nulldata script +// (<4-byte LE encoded height + 8-byte random>) +// - All script versions set to 0 +func CheckTreasuryBase(tx *wire.MsgTx) error { + // The transaction version must be the required treasury version. + if tx.Version != wire.TxVersionTreasury { + str := fmt.Sprintf("treasurybase transaction version is %d instead of %d", + tx.Version, wire.TxVersionTreasury) + return stakeRuleError(ErrTreasuryBaseInvalidTxVersion, str) } - // A TADD consists of one OP_TADD in PkScript[0] followed by an - // OP_RETURN in PkScript[1]. - if len(mtx.TxIn) != 1 || len(mtx.TxOut) != 2 { - return stakeRuleError(ErrTreasuryBaseInvalidCount, - fmt.Sprintf("invalid treasurybase in/out script "+ - "count: %v/%v", len(mtx.TxIn), - len(mtx.TxOut))) + // A treasurybase must have exactly one input and two outputs. + if len(tx.TxIn) != 1 { + str := fmt.Sprintf("treasurybase transaction has %d inputs instead of 1", + len(tx.TxIn)) + return stakeRuleError(ErrTreasuryBaseInvalidCount, str) + } + if len(tx.TxOut) != 2 { + str := fmt.Sprintf("treasurybase transaction has %d output(s) instead "+ + "of 2", len(tx.TxOut)) + return stakeRuleError(ErrTreasuryBaseInvalidCount, str) } - // Ensure that there is no SignatureScript on the zeroth input. - if len(mtx.TxIn[0].SignatureScript) != 0 { - return stakeRuleError(ErrTreasuryBaseInvalidLength, - "treasurybase input 0 contains a script") + // The first input signature script must be empty and its previous output + // must be a null outpoint (max value index, a zero hash, regular tx tree). + if len(tx.TxIn[0].SignatureScript) != 0 { + str := fmt.Sprintf("treasurybase input 0 signature script is %d "+ + "byte(s) instead of 0", len(tx.TxIn[0].SignatureScript)) + return stakeRuleError(ErrTreasuryBaseInvalidLength, str) + } + if !isNullOutpoint(tx) { + prevOut := &tx.TxIn[0].PreviousOutPoint + str := fmt.Sprintf("treasurybase input 0 previous output %s:%d:%d is "+ + "not a null outpoint", prevOut.Hash, prevOut.Index, prevOut.Tree) + return stakeRuleError(ErrTreasuryBaseInvalid, str) } // All output scripts must be version 0. const consensusScriptVer = 0 - for k := range mtx.TxOut { - if mtx.TxOut[k].Version != consensusScriptVer { - return stakeRuleError(ErrTreasuryBaseInvalidVersion, - fmt.Sprintf("invalid script version found in "+ - "treasurybase: output %v", k)) + for txOutIdx, txOut := range tx.TxOut { + if txOut.Version != consensusScriptVer { + str := fmt.Sprintf("treasurybase transaction output %d script "+ + "version is %d instead of %d", txOutIdx, txOut.Version, + consensusScriptVer) + return stakeRuleError(ErrTreasuryBaseInvalidVersion, str) } } - // First output must be a TADD - if len(mtx.TxOut[0].PkScript) != 1 || - mtx.TxOut[0].PkScript[0] != txscript.OP_TADD { - return stakeRuleError(ErrTreasuryBaseInvalidOpcode0, - "first treasurybase output must be a TADD") + // The first output must be a script that only consists of OP_TADD. + firstTxOut := tx.TxOut[0] + if len(firstTxOut.PkScript) != 1 { + str := fmt.Sprintf("treasurybase transaction output 0 script length "+ + "is %d bytes instead of 1 byte", len(firstTxOut.PkScript)) + return stakeRuleError(ErrTreasuryBaseInvalidOpcode0, str) } - - // Required OP_RETURN, OP_DATA_12 <4 bytes le encoded height> - // <8 bytes random> = 14 bytes total. - if len(mtx.TxOut[1].PkScript) != 14 || - mtx.TxOut[1].PkScript[0] != txscript.OP_RETURN || - mtx.TxOut[1].PkScript[1] != txscript.OP_DATA_12 { - return stakeRuleError(ErrTreasuryBaseInvalidOpcode1, - "second treasurybase output must be an OP_RETURN "+ - "OP_DATA_12 data script") + if firstTxOut.PkScript[0] != txscript.OP_TADD { + str := fmt.Sprintf("treasurybase transaction output 0 script is 0x%x "+ + "instead of OP_TADD (0x%x)", firstTxOut.PkScript[0], + txscript.OP_TADD) + return stakeRuleError(ErrTreasuryBaseInvalidOpcode0, str) } - if !isNullOutpoint(mtx) { - return stakeRuleError(ErrTreasuryBaseInvalid, - "invalid treasurybase constants") + // The second output must be an OP_RETURN followed by a 12 byte data push. + opRetTxOut := tx.TxOut[1] + if !txscript.IsStrictNullData(opRetTxOut.Version, opRetTxOut.PkScript, 12) { + const str = "treasurybase transaction output 1 is not an OP_RETURN " + + "followed by a 12 byte data push" + return stakeRuleError(ErrTreasuryBaseInvalidOpcode1, str) } return nil } -// CheckTreasuryBase verifies that the provided MsgTx is a treasury base. This -// is exported for testing purposes. -func CheckTreasuryBase(mtx *wire.MsgTx) error { - return checkTreasuryBase(mtx) -} - -// IsTreasuryBase returns true if the provided transaction is a treasury base -// transaction. +// IsTreasuryBase returns whether or not the provided transaction satisfies the +// structural requirements to be a valid treasurybase transaction. +// +// See the [CheckTreasuryBase] documentation for more details. func IsTreasuryBase(tx *wire.MsgTx) bool { - return checkTreasuryBase(tx) == nil + return CheckTreasuryBase(tx) == nil } diff --git a/blockchain/stake/treasury_test.go b/blockchain/stake/treasury_test.go index 034698960e..cb9dda4651 100644 --- a/blockchain/stake/treasury_test.go +++ b/blockchain/stake/treasury_test.go @@ -725,7 +725,7 @@ func TestTreasuryBaseErrors(t *testing.T) { expected: ErrTreasuryBaseInvalid, }} for _, test := range tests { - err := checkTreasuryBase(test.tx) + err := CheckTreasuryBase(test.tx) if !errors.Is(err, test.expected) { t.Errorf("%q: unexpected error -- got %v, want %v", test.name, err, test.expected) From 8a1f132ea009471e5b8d68f8a9a2a8218f32ba9b Mon Sep 17 00:00:00 2001 From: Dave Collins Date: Wed, 6 May 2026 01:18:38 -0500 Subject: [PATCH 10/19] blockchain: Cleanup proof of stake checks. This performs some light cleanup of the checkProofOfStake function to make it more consistent with the rest of the code make the error messages more accurate and useful. --- internal/blockchain/validate.go | 48 ++++++++++++++++----------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/internal/blockchain/validate.go b/internal/blockchain/validate.go index 216377cb97..cca5ad7a81 100644 --- a/internal/blockchain/validate.go +++ b/internal/blockchain/validate.go @@ -629,32 +629,30 @@ func CheckTransaction(tx *wire.MsgTx, params *chaincfg.Params, flags AgendaFlags // checkProofOfStake ensures that all ticket purchases in the block pay at least // the amount required by the block header stake bits which indicate the target // stake difficulty (aka ticket price) as claimed. -func checkProofOfStake(block *dcrutil.Block, posLimit int64) error { - msgBlock := block.MsgBlock() - for _, staketx := range block.STransactions() { - msgTx := staketx.MsgTx() - if stake.IsSStx(msgTx) { - commitValue := msgTx.TxOut[0].Value - - // Check for underflow block sbits. - if commitValue < msgBlock.Header.SBits { - errStr := fmt.Sprintf("Stake tx %v has a "+ - "commitment value less than the "+ - "minimum stake difficulty specified in"+ - " the block (%v)", staketx.Hash(), - msgBlock.Header.SBits) - return ruleError(ErrNotEnoughStake, errStr) - } +func checkProofOfStake(block *dcrutil.Block, minStakeDiff int64) error { + header := &block.MsgBlock().Header + for _, stx := range block.STransactions() { + msgTx := stx.MsgTx() + if !stake.IsSStx(msgTx) { + continue + } - // Check if it's above the PoS limit. - if commitValue < posLimit { - errStr := fmt.Sprintf("Stake tx %v has a "+ - "commitment value less than the "+ - "minimum stake difficulty for the "+ - "network (%v)", staketx.Hash(), - posLimit) - return ruleError(ErrStakeBelowMinimum, errStr) - } + // The ticket price must not be below the stake difficulty claimed by + // the block header. + ticketPaidAmt := msgTx.TxOut[submissionOutputIdx].Value + if ticketPaidAmt < header.SBits { + str := fmt.Sprintf("ticket %v pays %v below the stake difficulty "+ + "%v committed to by the block header", stx.Hash(), + ticketPaidAmt, header.SBits) + return ruleError(ErrNotEnoughStake, str) + } + + // The ticket price must not be below the network minimum. + if ticketPaidAmt < minStakeDiff { + str := fmt.Sprintf("ticket %v pays %v below the network minimum "+ + "stake difficulty of %v", stx.Hash(), ticketPaidAmt, + minStakeDiff) + return ruleError(ErrStakeBelowMinimum, str) } } From c3360c71274e853b32679bdfc876a36830d83bd7 Mon Sep 17 00:00:00 2001 From: Dave Collins Date: Wed, 6 May 2026 07:07:30 -0500 Subject: [PATCH 11/19] blockchain: New add funcs with overflow detection. Currently, all overflow detection is done inline in multiple places throughout the blockchain code. It would be more ergonomic, consistent, and less error prone to instead use well-tested funcs dedicated to that purpose. To pave the way, this adds two new functions for adding arguments with a returned flag that indicates whether the result is safe to use (that is no overflow or underflow occurred). One variant is for unsigned ints (uint16, uint32, and uint64) and the other is for signed ints (int16, int32, and int64). It only introduces the funcs and does not modify any code to use them. --- internal/blockchain/checkedmath.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 internal/blockchain/checkedmath.go diff --git a/internal/blockchain/checkedmath.go b/internal/blockchain/checkedmath.go new file mode 100644 index 0000000000..99faf9e78d --- /dev/null +++ b/internal/blockchain/checkedmath.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package blockchain + +// addUnsigned returns the sum of the two unsigned ints of the same size and +// whether or not the result is safe to use (aka no overflow occurred). +func addUnsigned[T ~uint16 | ~uint32 | ~uint64](a, b T) (T, bool) { + sum := a + b + return sum, sum >= a +} + +// addSigned returns the sum of the two signed ints of the same size and whether +// or not the result is safe to use (aka no overflow or underflow occurred). +func addSigned[T ~int16 | ~int32 | ~int64](a, b T) (T, bool) { + // Overflow only occurs when adding a positive value when the sum is <= to + // left summand. Likewise, underflow only occurs when adding a non-positive + // value when the sum is > the left summand. The following is the logical + // negation of the result of testing both conditions at once so the returned + // flag indicates their absence. + sum := a + b + return sum, (sum > a) == (b > 0) +} From 1c7f57f255f2eaaae1ac4e026e0ca20d84cf2984 Mon Sep 17 00:00:00 2001 From: Dave Collins Date: Wed, 6 May 2026 07:07:31 -0500 Subject: [PATCH 12/19] blockchain: Add tests for new add funcs. This adds comprehensive tests for the new addUnsigned and addSigned funcs for all supported types. --- internal/blockchain/checkedmath_test.go | 130 ++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 internal/blockchain/checkedmath_test.go diff --git a/internal/blockchain/checkedmath_test.go b/internal/blockchain/checkedmath_test.go new file mode 100644 index 0000000000..7ce7f81c6e --- /dev/null +++ b/internal/blockchain/checkedmath_test.go @@ -0,0 +1,130 @@ +// Copyright (c) 2026 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package blockchain + +import ( + "testing" +) + +// testAddUnsigned ensures [addUnsigned] produces the expected results for the +// given supported unsigned type. It uses generics to avoid repeating the tests +// for each type. +func testAddUnsigned[T uint16 | uint32 | uint64](t *testing.T, typ string) { + t.Helper() + + var maxVal = ^T(0) + tests := []struct { + name string // test description + a, b T // unsigned vals to test + sum T // expected sum + ok bool // expected result + }{ + // No overflow cases. + {"zero", 0, 0, 0, true}, + {"max + zero", maxVal, 0, maxVal, true}, + {"zero + max", 0, maxVal, maxVal, true}, + {"small positive", 10, 20, 30, true}, + {"max edge", maxVal - 1, 1, maxVal, true}, + {"max edge rev", 1, maxVal - 1, maxVal, true}, + {"halfmax + halfmax", maxVal / 2, maxVal / 2, maxVal - 1, true}, + {"mid + halfmax", maxVal/2 + 1, maxVal / 2, maxVal, true}, + {"halfmax + mid", maxVal / 2, maxVal/2 + 1, maxVal, true}, + + // Overflow cases. + {"max + 1 overflow exact", maxVal, 1, 0, false}, + {"1 + max overflow exact", 1, maxVal, 0, false}, + {"small overflow", maxVal - 5, 6, 0, false}, + {"small overflow rev", 6, maxVal - 5, 0, false}, + {"mid overflow", maxVal/2 + 1, maxVal/2 + 1, 0, false}, + {"mid + max overflow", maxVal/2 + 1, maxVal, maxVal / 2, false}, + {"max + mid overflow", maxVal, maxVal/2 + 1, maxVal / 2, false}, + } + + for _, test := range tests { + sum, ok := addUnsigned(test.a, test.b) + if sum != test.sum || ok != test.ok { + t.Errorf("%q (%s): unexpected result - got (%v, %v), want (%v, %v)", + test.name, typ, sum, ok, test.sum, test.ok) + } + } +} + +// TestAddUnsigned ensures [addUnsigned] produces the expected results for all +// three supported unsigned int types (uint16, uint32, uint64). +func TestAddUnsigned(t *testing.T) { + testAddUnsigned[uint16](t, "uint16") + testAddUnsigned[uint32](t, "uint32") + testAddUnsigned[uint64](t, "uint64") +} + +// testAddSigned ensures [addSigned] produces the expected results for the given +// supported signed type. It uses generics to avoid repeating the tests for +// each type. +func testAddSigned[T int16 | int32 | int64](t *testing.T, typ string, maxVal T) { + t.Helper() + + minVal := ^maxVal + tests := []struct { + name string // test description + a, b T // signed vals to test + sum T // expected sum + ok bool // expected result + }{ + // No overflow or underflow cases. + {"zero", 0, 0, 0, true}, + {"min + zero", minVal, 0, minVal, true}, + {"zero + min", 0, minVal, minVal, true}, + {"max + zero", maxVal, 0, maxVal, true}, + {"zero + max", 0, maxVal, maxVal, true}, + {"small positive", 10, 20, 30, true}, + {"small negative", -10, -20, -30, true}, + {"mixed small", 100, -50, 50, true}, + {"mixed small rev", -100, 50, -50, true}, + {"min edge", minVal + 1, -1, minVal, true}, + {"max edge", maxVal - 1, 1, maxVal, true}, + {"min + max", minVal, maxVal, -1, true}, + {"max + min", maxVal, minVal, -1, true}, + {"halfmin + halfmin", minVal / 2, minVal / 2, minVal, true}, + {"mid + min", maxVal/2 + 1, minVal, minVal / 2, true}, + {"min + mid", minVal, maxVal/2 + 1, minVal / 2, true}, + + // Overflow cases. + {"max + 1 overflow exact", maxVal, 1, minVal, false}, + {"1 + max overflow exact", 1, maxVal, minVal, false}, + {"small overflow", maxVal - 5, 6, minVal, false}, + {"small overflow rev", 6, maxVal - 5, minVal, false}, + {"pos to neg mid overflow", maxVal/2 + 1, maxVal/2 + 1, minVal, false}, + {"mid + max overflow", maxVal/2 + 1, maxVal, minVal/2 - 1, false}, + {"max + mid overflow", maxVal, maxVal/2 + 1, minVal/2 - 1, false}, + {"max + max overflow", maxVal, maxVal, -2, false}, + + // Underflow cases. + {"min + (-1) underflow exact", minVal, -1, maxVal, false}, + {"-1 + min underflow exact", -1, minVal, maxVal, false}, + {"small underflow", minVal + 5, -6, maxVal, false}, + {"small underflow rev", -6, minVal + 5, maxVal, false}, + {"neg to pos mid underflow", minVal/2 - 1, minVal / 2, maxVal, false}, + {"neg to pos mid underflow rev", minVal / 2, minVal/2 - 1, maxVal, false}, + {"halfmin + min underflow", minVal / 2, minVal, maxVal/2 + 1, false}, + {"min + halfmin underflow", minVal, minVal / 2, maxVal/2 + 1, false}, + {"min + min underflow", minVal, minVal, 0, false}, + } + + for _, test := range tests { + sum, ok := addSigned(test.a, test.b) + if sum != test.sum || ok != test.ok { + t.Errorf("%q (%s): unexpected result - got (%v, %v), want (%v, %v)", + test.name, typ, sum, ok, test.sum, test.ok) + } + } +} + +// TestAddSigned ensures [addSigned] produces the expected results for all three +// supported signed int types (int16, int32, int64). +func TestAddSigned(t *testing.T) { + testAddSigned[int16](t, "int16", 1<<15-1) + testAddSigned[int32](t, "int32", 1<<31-1) + testAddSigned[int64](t, "int64", 1<<63-1) +} From 440286977cd2bbad1edeea127e16ea07bae9b9f3 Mon Sep 17 00:00:00 2001 From: Dave Collins Date: Wed, 6 May 2026 07:07:32 -0500 Subject: [PATCH 13/19] multi: Unsigned sigop counts and cleaner overflow. Consensus code should ideally always use types of a specific size so there is no possibility of divergent behavior when compiled on different architectures due to differing upper bounds. Further, unsigned types for values that can never be negative should always be preferred. The current signature operations counting code does not adhere to that practice and uses plain ints which means the maximum possible value technically changes depending on the architecture. While this is not currently an issue due to a combination of various limits making it impossible to get anywhere near the limits of the smallest supported architecture (32-bit) and overflow detection, these types of hidden assumptions can easily lead to bugs over time as new features are introduced. This remedies that by modifying the consensus level signature operation counting to use an fixed unsigned 32-bit integer instead of an architecture dependent signed integer. Ideally, the return types on the underlying counting funcs in the script engine would be updated to match and avoid the need to cast, but that would require a major API version bump since it is a public module, so this limits the changes to the internal blockchain, mempool, and mining packages. While here, it also switches to the new consolidated add funcs with cleaner overflow detection versus the current more ad-hoc inline detection. Note that there is no risk of consensus divergence due to the aforementioned impossibility of hitting the conditions with the current combination of parameters and limits. --- internal/blockchain/validate.go | 221 ++++++++++++++----------- internal/mempool/mempool.go | 21 +-- internal/mining/mining.go | 63 ++++--- internal/mining/mining_harness_test.go | 20 +-- internal/mining/mining_view_test.go | 2 +- server.go | 2 +- 6 files changed, 177 insertions(+), 152 deletions(-) diff --git a/internal/blockchain/validate.go b/internal/blockchain/validate.go index cca5ad7a81..31e52a7913 100644 --- a/internal/blockchain/validate.go +++ b/internal/blockchain/validate.go @@ -2161,9 +2161,9 @@ func (b *BlockChain) checkBlockContext(block *dcrutil.Block, prevNode *blockNode return ruleError(ErrRevocationsMismatch, str) } - // The number of signature operations must be less than the maximum allowed - // per block. - totalSigOps := 0 + // The number of signature operations must not overflow the accumulator and + // be less than the maximum allowed per block. + var totalSigOps uint32 regularTxns := block.Transactions() stakeTxns := block.STransactions() allTxns := make([]*dcrutil.Tx, 0, len(regularTxns)+len(stakeTxns)) @@ -2172,12 +2172,21 @@ func (b *BlockChain) checkBlockContext(block *dcrutil.Block, prevNode *blockNode for _, tx := range allTxns { msgTx := tx.MsgTx() - // We could potentially overflow the accumulator so check for overflow. - lastSigOps := totalSigOps isCoinBase := standalone.IsCoinBaseTx(msgTx, isTreasuryEnabled) isSSGen := stake.IsSSGen(msgTx) - totalSigOps += CountSigOps(tx, isCoinBase, isSSGen, isTreasuryEnabled) - if totalSigOps < lastSigOps || totalSigOps > MaxSigOpsPerBlock { + numSigOps, err := countSigOps(tx, isCoinBase, isSSGen, isTreasuryEnabled) + if err != nil { + return err + } + + var ok bool + totalSigOps, ok = addUnsigned(totalSigOps, numSigOps) + if !ok { + str := fmt.Sprintf("tx %v causes block signature operation count "+ + "to overflow", tx.Hash()) + return ruleError(ErrTooManySigOps, str) + } + if totalSigOps > MaxSigOpsPerBlock { str := fmt.Sprintf("block contains too many signature operations "+ "- got %v, max %v", totalSigOps, MaxSigOpsPerBlock) return ruleError(ErrTooManySigOps, str) @@ -3404,58 +3413,64 @@ func CheckTransactionInputs(subsidyCache *standalone.SubsidyCache, return txFeeInAtom, nil } -// CountSigOps returns the number of signature operations for all transaction -// input and output scripts in the provided transaction. This uses the -// quicker, but imprecise, signature operation counting mechanism from -// txscript. -func CountSigOps(tx *dcrutil.Tx, isCoinBaseTx bool, isSSGen bool, isTreasuryEnabled bool) int { +// countSigOps returns the number of signature operations for all input and +// output scripts in the provided transaction. This uses the quicker, but +// imprecise, signature operation counting mechanism from the script engine. +func countSigOps(tx *dcrutil.Tx, isCoinBase bool, isVote bool, isTreasuryEnabled bool) (uint32, error) { + // Treasurybases do not have any signature operations. msgTx := tx.MsgTx() - - totalSigOps := 0 - if isTreasuryEnabled && stake.IsTreasuryBase(msgTx) { - return totalSigOps + return 0, nil } - if !isCoinBaseTx { - // Accumulate the number of signature operations in all - // transaction inputs. - for i, txIn := range msgTx.TxIn { - // Skip stakebase inputs. - if isSSGen && i == 0 { - continue - } + // Determine which transaction inputs to consider. Coinbase transactions + // and the first input (aka stakebase) of a vote do not have normal input + // scripts to consider. + var txInStartIdx int + txIns := msgTx.TxIn + if isCoinBase { + txIns = nil + } else if isVote && len(txIns) > 0 { + txInStartIdx = 1 + txIns = txIns[txInStartIdx:] + } - numSigOps := txscript.GetSigOpCount(txIn.SignatureScript, - isTreasuryEnabled) - totalSigOps += numSigOps + // Accumulate the number of signature operations in all relevant transaction + // inputs. + var totalSigOps uint32 + var ok bool + for txInIdx, txIn := range txIns { + sigScript := txIn.SignatureScript + numSigOps := txscript.GetSigOpCount(sigScript, isTreasuryEnabled) + totalSigOps, ok = addUnsigned(totalSigOps, uint32(numSigOps)) + if !ok { + str := fmt.Sprintf("input %v:%d signature script causes signature "+ + "operation count to overflow", tx.Hash(), txInIdx+txInStartIdx) + return 0, ruleError(ErrTooManySigOps, str) } } - // Accumulate the number of signature operations in all transaction - // outputs. - for _, txOut := range msgTx.TxOut { - numSigOps := txscript.GetSigOpCount(txOut.PkScript, - isTreasuryEnabled) - totalSigOps += numSigOps + // Accumulate the number of signature operations in all transaction outputs. + for txOutIdx, txOut := range msgTx.TxOut { + numSigOps := txscript.GetSigOpCount(txOut.PkScript, isTreasuryEnabled) + totalSigOps, ok = addUnsigned(totalSigOps, uint32(numSigOps)) + if !ok { + str := fmt.Sprintf("output %v:%d public key script causes "+ + "signature operation count to overflow", tx.Hash(), txOutIdx) + return 0, ruleError(ErrTooManySigOps, str) + } } - return totalSigOps + return totalSigOps, nil } -// CountP2SHSigOps returns the number of signature operations for all input +// countP2SHSigOps returns the number of signature operations for all input // transactions which are of the pay-to-script-hash type. This uses the // precise, signature operation counting mechanism from the script engine which // requires access to the input transaction scripts. -func CountP2SHSigOps(tx *dcrutil.Tx, isCoinBaseTx bool, isStakeBaseTx bool, view *UtxoViewpoint, isTreasuryEnabled bool) (int, error) { +func countP2SHSigOps(tx *dcrutil.Tx, isCoinBase bool, isVote bool, view *UtxoViewpoint, isTreasuryEnabled bool) (uint32, error) { // Coinbase transactions have no interesting inputs. - if isCoinBaseTx { - return 0, nil - } - - // Stakebase (SSGen) transactions have no P2SH inputs. Same with SSRtx, - // but they will still pass the checks below. - if isStakeBaseTx { + if isCoinBase { return 0, nil } @@ -3469,42 +3484,44 @@ func CountP2SHSigOps(tx *dcrutil.Tx, isCoinBaseTx bool, isStakeBaseTx bool, view } } - // Accumulate the number of signature operations in all transaction - // inputs. - totalSigOps := 0 - for txInIndex, txIn := range msgTx.TxIn { + // The first input (aka stakebase) of votes have no P2SH inputs. + var txInStartIdx int + txIns := msgTx.TxIn + if isVote && len(txIns) > 0 { + txInStartIdx = 1 + txIns = txIns[txInStartIdx:] + } + + // Accumulate the number of signature operations from all pay-to-script-hash + // transaction inputs. + var totalSigOps uint32 + for txInIndex, txIn := range txIns { // Ensure the referenced input transaction is available. txInOutpoint := txIn.PreviousOutPoint utxoEntry := view.LookupEntry(txInOutpoint) if utxoEntry == nil || utxoEntry.IsSpent() { - str := fmt.Sprintf("output %v referenced from "+ - "transaction %s:%d either does not exist or "+ - "has already been spent", txInOutpoint, - tx.Hash(), txInIndex) + str := fmt.Sprintf("output %v referenced from transaction %s:%d "+ + "either does not exist or has already been spent", txInOutpoint, + tx.Hash(), txInIndex+txInStartIdx) return 0, ruleError(ErrMissingTxOut, str) } - // We're only interested in pay-to-script-hash types, so skip - // this input if it's not one. + // Skip inputs that aren't pay-to-script-hash. pkScript := utxoEntry.PkScript() if !txscript.IsPayToScriptHash(pkScript) { continue } - // Count the precise number of signature operations in the - // referenced public key script. + // Count the precise number of signature operations in the referenced + // public key script. + var ok bool sigScript := txIn.SignatureScript numSigOps := txscript.GetPreciseSigOpCount(sigScript, pkScript, isTreasuryEnabled) - - // We could potentially overflow the accumulator so check for - // overflow. - lastSigOps := totalSigOps - totalSigOps += numSigOps - if totalSigOps < lastSigOps { - str := fmt.Sprintf("the public key script from output "+ - "%v contains too many signature operations - "+ - "overflow", txInOutpoint) + totalSigOps, ok = addUnsigned(totalSigOps, uint32(numSigOps)) + if !ok { + str := fmt.Sprintf("output %v public key script causes signature "+ + "operations count to overflow", txInOutpoint) return 0, ruleError(ErrTooManySigOps, str) } } @@ -3512,42 +3529,32 @@ func CountP2SHSigOps(tx *dcrutil.Tx, isCoinBaseTx bool, isStakeBaseTx bool, view return totalSigOps, nil } -// checkNumSigOps Checks the number of P2SH signature operations to make -// sure they don't overflow the limits. It takes a cumulative number of sig -// ops as an argument and increments will each call. -func checkNumSigOps(tx *dcrutil.Tx, view *UtxoViewpoint, index int, stakeTree bool, cumulativeSigOps int, isTreasuryEnabled bool) (int, error) { - msgTx := tx.MsgTx() - isSSGen := stake.IsSSGen(msgTx) - isCoinbaseTx := (index == 0) && !stakeTree - numsigOps := CountSigOps(tx, isCoinbaseTx, isSSGen, isTreasuryEnabled) - - // Since the first (and only the first) transaction has already been - // verified to be a coinbase transaction, use (i == 0) && TxTree as an - // optimization for the flag to countP2SHSigOps for whether or not the - // transaction is a coinbase transaction rather than having to do a - // full coinbase check again. - numP2SHSigOps, err := CountP2SHSigOps(tx, isCoinbaseTx, isSSGen, view, - isTreasuryEnabled) +// CountTotalSigOps returns the total number of signature operations for the +// given transaction. This includes all input and output scripts as well as +// signature operations in any redeemed pay-to-script-hash inputs. +func CountTotalSigOps(tx *dcrutil.Tx, isCoinBase, isVote bool, view *UtxoViewpoint, isTreasuryEnabled bool) (uint32, error) { + // Count the number of regular signature operations. + numSigOps, err := countSigOps(tx, isCoinBase, isVote, isTreasuryEnabled) if err != nil { - log.Tracef("CountP2SHSigOps failed; error returned %v", err) return 0, err } - startCumSigOps := cumulativeSigOps - cumulativeSigOps += numsigOps - cumulativeSigOps += numP2SHSigOps + // Count the number of precise pay-to-script-hash signature operations. + numP2SHSigOps, err := countP2SHSigOps(tx, isCoinBase, isVote, view, + isTreasuryEnabled) + if err != nil { + return 0, err + } - // Check for overflow or going over the limits. We have to do - // this on every loop iteration to avoid overflow. - if cumulativeSigOps < startCumSigOps || - cumulativeSigOps > MaxSigOpsPerBlock { - str := fmt.Sprintf("block contains too many signature "+ - "operations - got %v, max %v", cumulativeSigOps, - MaxSigOpsPerBlock) + // Ensure the combined total does not overflow. + totalSigOps, ok := addUnsigned(numSigOps, numP2SHSigOps) + if !ok { + str := fmt.Sprintf("tx %v total signature operations overflow", + tx.Hash()) return 0, ruleError(ErrTooManySigOps, str) } - return cumulativeSigOps, nil + return totalSigOps, nil } // checkStakeBaseAmounts calculates the total amount given as subsidy from @@ -3735,16 +3742,34 @@ func (b *BlockChain) checkTransactionsAndConnect(inputFees dcrutil.Amount, } totalFees := int64(inputFees) // Stake tx tree carry forward prevHeader := node.parent.Header() - var cumulativeSigOps int + var cumulativeSigOps uint32 for idx, tx := range txs { - // Ensure that the number of signature operations is not beyond - // the consensus limit. - var err error - cumulativeSigOps, err = checkNumSigOps(tx, view, idx, stakeTree, - cumulativeSigOps, isTreasuryEnabled) + // Since the first (and only the first) transaction has already been + // verified to be a coinbase transaction, use (idx == 0) && !stakeTree + // as an optimization rather than having to do a full coinbase check + // again. + isCoinBase := (idx == 0) && !stakeTree + isVote := stakeTree && stake.IsSSGen(tx.MsgTx()) + + // The number of signature operations must not overflow the accumulator + // and be less than the maximum allowed per block. + var ok bool + numSigOps, err := CountTotalSigOps(tx, isCoinBase, isVote, view, + isTreasuryEnabled) if err != nil { return err } + cumulativeSigOps, ok = addUnsigned(cumulativeSigOps, numSigOps) + if !ok { + str := fmt.Sprintf("tx %v causes block signature operation count "+ + "to overflow", tx.Hash()) + return ruleError(ErrTooManySigOps, str) + } + if cumulativeSigOps > MaxSigOpsPerBlock { + str := fmt.Sprintf("block contains too many signature operations "+ + "- got %v, max %v", cumulativeSigOps, MaxSigOpsPerBlock) + return ruleError(ErrTooManySigOps, str) + } // Perform a series of checks on the inputs to the transaction to ensure // they are valid and calculate the total fees for it. diff --git a/internal/mempool/mempool.go b/internal/mempool/mempool.go index a69a1293f6..6985a76b33 100644 --- a/internal/mempool/mempool.go +++ b/internal/mempool/mempool.go @@ -1130,7 +1130,7 @@ func (mp *TxPool) FetchTransaction(txHash *chainhash.Hash) (*dcrutil.Tx, error) // newTxDesc returns a new TxDesc instance that captures mempool state // relevant to the provided transaction at the current time. func (mp *TxPool) newTxDesc(tx *dcrutil.Tx, txType stake.TxType, height int64, - fee int64, totalSigOps int, txSize int64) *TxDesc { + fee int64, totalSigOps uint32, txSize int64) *TxDesc { return &TxDesc{ TxDesc: mining.TxDesc{ @@ -1582,13 +1582,13 @@ func (mp *TxPool) maybeAcceptTransaction(tx *dcrutil.Tx, isNew, allowHighFees, // you should add code here to check that the transaction does a // reasonable number of ECDSA signature verifications. - // Don't allow transactions with an excessive number of signature - // operations which would result in making it impossible to mine. Since - // the coinbase address itself can contain signature operations, the - // maximum allowed signature operations per transaction is less than - // the maximum allowed signature operations per block. - numP2SHSigOps, err := blockchain.CountP2SHSigOps(tx, false, - (txType == stake.TxTypeSSGen), utxoView, isTreasuryEnabled) + // Don't allow transactions with an excessive number of signature operations + // which would result in making it impossible to mine. Since the coinbase + // address itself can contain signature operations, the maximum allowed + // signature operations per transaction is less than the maximum allowed + // signature operations per block. + totalSigOps, err := blockchain.CountTotalSigOps(tx, false, isVote, utxoView, + isTreasuryEnabled) if err != nil { var cerr blockchain.RuleError if errors.As(err, &cerr) { @@ -1596,10 +1596,7 @@ func (mp *TxPool) maybeAcceptTransaction(tx *dcrutil.Tx, isNew, allowHighFees, } return nil, err } - - numSigOps := blockchain.CountSigOps(tx, false, isVote, isTreasuryEnabled) - totalSigOps := numP2SHSigOps + numSigOps - if totalSigOps > mp.cfg.Policy.MaxSigOpsPerTx { + if totalSigOps > uint32(mp.cfg.Policy.MaxSigOpsPerTx) { str := fmt.Sprintf("transaction %v has too many sigops: %d > %d", txHash, totalSigOps, mp.cfg.Policy.MaxSigOpsPerTx) return nil, txRuleError(ErrNonStandard, str) diff --git a/internal/mining/mining.go b/internal/mining/mining.go index 26ef903418..302a837dc4 100644 --- a/internal/mining/mining.go +++ b/internal/mining/mining.go @@ -110,10 +110,12 @@ type Config struct { // tspend has enough votes to be included in a block AFTER the specified block. CheckTSpendHasVotes func(prevHash chainhash.Hash, tspend *dcrutil.Tx) error - // CountSigOps defines the function to use to count the number of signature - // operations for all transaction input and output scripts in the provided - // transaction. - CountSigOps func(tx *dcrutil.Tx, isCoinBaseTx bool, isSSGen bool, isTreasuryEnabled bool) int + // CountTotalSigOps defines the function to use to count the total number of + // signature operations for the given transaction. This includes all input + // and output scripts as well as signature operations in any redeemed + // pay-to-script-hash inputs. + CountTotalSigOps func(tx *dcrutil.Tx, isCoinBaseTx, isVoteTx bool, + view *blockchain.UtxoViewpoint, isTreasuryEnabled bool) (uint32, error) // FetchUtxoEntry defines the function to use to load and return the requested // unspent transaction output from the point of view of the main chain tip. @@ -224,7 +226,7 @@ type TxDesc struct { Fee int64 // TotalSigOps is the total signature operations for this transaction. - TotalSigOps int + TotalSigOps uint32 // TxSize is the size of the transaction. TxSize int64 @@ -240,7 +242,7 @@ type TxAncestorStats struct { SizeBytes int64 // TotalSigOps is the total number of signature operations of all ancestors. - TotalSigOps int + TotalSigOps uint32 // NumAncestors is the total number of ancestors for a given transaction. NumAncestors int @@ -421,7 +423,7 @@ type BlockTemplate struct { // SigOpCounts contains the number of signature operations each // transaction in the generated template performs. - SigOpCounts []int64 + SigOpCounts []uint64 // Height is the height at which the block template connects to the main // chain. @@ -879,7 +881,7 @@ func (g *BlkTmplGenerator) handleTooFewVoters(nextHeight int64, bt := &BlockTemplate{ Block: &block, Fees: []int64{0}, - SigOpCounts: []int64{0}, + SigOpCounts: []uint64{0}, Height: int64(tipHeader.Height), ValidPayAddress: miningAddress != nil, } @@ -992,12 +994,18 @@ func (g *BlkTmplGenerator) createRevocationFromTicket(ticketHash *chainhash.Hash } revocationTx := dcrutil.NewTx(revocationMsgTx) revocationTx.SetTree(wire.TxTreeStake) + + totalSigOps, err := g.cfg.CountTotalSigOps(revocationTx, false, false, + blockUtxos, isTreasuryEnabled) + if err != nil { + return nil, err + } + txDesc := &TxDesc{ - Tx: revocationTx, - Type: stake.TxTypeSSRtx, - TotalSigOps: g.cfg.CountSigOps(revocationTx, false, false, - isTreasuryEnabled), - TxSize: int64(revocationMsgTx.SerializeSize()), + Tx: revocationTx, + Type: stake.TxTypeSSRtx, + TotalSigOps: totalSigOps, + TxSize: int64(revocationMsgTx.SerializeSize()), } return txDesc, nil @@ -1316,8 +1324,8 @@ func (g *BlkTmplGenerator) NewBlockTemplate(payToAddress stdaddr.Address) (*Bloc // the coinbase fee which will be updated later. txFees := make([]int64, 0, len(sourceTxns)) txFeesMap := make(map[chainhash.Hash]int64) - txSigOpCounts := make([]int64, 0, len(sourceTxns)) - txSigOpCountsMap := make(map[chainhash.Hash]int64) + txSigOpCounts := make([]uint64, 0, len(sourceTxns)) + txSigOpCountsMap := make(map[chainhash.Hash]uint64) txFees = append(txFees, -1) // Updated once known log.Debugf("Considering %d transactions for inclusion to new block", @@ -1443,7 +1451,7 @@ mempoolLoop: // trees if they fail one of the stake checks below the priorityQueue // pop loop. This is buggy, but not catastrophic behaviour. A future // release should fix it. TODO - blockSigOps := int64(0) + blockSigOps := uint64(0) totalFees := int64(0) numSStx := 0 @@ -1693,8 +1701,8 @@ nextPriorityQueueItem: // Enforce maximum signature operations per block. Also check // for overflow. - numSigOps := int64(prioItem.txDesc.TotalSigOps) - numSigOpsBundle := numSigOps + int64(ancestorStats.TotalSigOps) + numSigOps := uint64(prioItem.txDesc.TotalSigOps) + numSigOpsBundle := numSigOps + uint64(ancestorStats.TotalSigOps) if blockSigOps+numSigOpsBundle < blockSigOps || blockSigOps+numSigOpsBundle > blockchain.MaxSigOpsPerBlock { log.Tracef("Skipping tx %s because it would "+ @@ -1778,7 +1786,7 @@ nextPriorityQueueItem: // template. blockTxns = append(blockTxns, bundledTx) blockSize += uint32(bundledTx.MsgTx().SerializeSize()) - bundledTxSigOps := int64(bundledTxDesc.TotalSigOps) + bundledTxSigOps := uint64(bundledTxDesc.TotalSigOps) blockSigOps += bundledTxSigOps // Accumulate the SStxs in the block, because only a certain number @@ -2058,16 +2066,19 @@ nextPriorityQueueItem: g.cfg.ChainParams, isTreasuryEnabled, subsidySplitVariant) coinbaseTx.SetTree(wire.TxTreeRegular) - numCoinbaseSigOps := int64(g.cfg.CountSigOps(coinbaseTx, true, - false, isTreasuryEnabled)) + numCoinbaseSigOps, err := g.cfg.CountTotalSigOps(coinbaseTx, true, false, + blockUtxos, isTreasuryEnabled) + if err != nil { + log.Debug(err) + return nil, err + } blockSize += uint32(coinbaseTx.MsgTx().SerializeSize()) - blockSigOps += numCoinbaseSigOps + blockSigOps += uint64(numCoinbaseSigOps) txFeesMap[*coinbaseTx.Hash()] = 0 - txSigOpCountsMap[*coinbaseTx.Hash()] = numCoinbaseSigOps + txSigOpCountsMap[*coinbaseTx.Hash()] = uint64(numCoinbaseSigOps) if treasuryBase != nil { txFeesMap[*treasuryBase.Hash()] = 0 - n := int64(g.cfg.CountSigOps(treasuryBase, true, false, isTreasuryEnabled)) - txSigOpCountsMap[*treasuryBase.Hash()] = n + txSigOpCountsMap[*treasuryBase.Hash()] = 0 } // Build tx lists for regular tx. @@ -2129,7 +2140,7 @@ nextPriorityQueueItem: totalFees /= int64(g.cfg.ChainParams.TicketsPerBlock) } - txSigOpCounts = append(txSigOpCounts, numCoinbaseSigOps) + txSigOpCounts = append(txSigOpCounts, uint64(numCoinbaseSigOps)) // Now that the actual transactions have been selected, update the // block size for the real transaction count and coinbase value with diff --git a/internal/mining/mining_harness_test.go b/internal/mining/mining_harness_test.go index 7ce294a8a8..4eaa938fa1 100644 --- a/internal/mining/mining_harness_test.go +++ b/internal/mining/mining_harness_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2020-2024 The Decred developers +// Copyright (c) 2020-2026 The Decred developers // Use of this source code is governed by an ISC // license that can be found in the LICENSE file. @@ -392,23 +392,15 @@ func (p *fakeTxSource) IsRegTxTreeKnownDisapproved(hash *chainhash.Hash) bool { // CountTotalSigOps returns the total number of signature operations for the // given transaction. -func (p *fakeTxSource) CountTotalSigOps(tx *dcrutil.Tx, txType stake.TxType) (int, error) { +func (p *fakeTxSource) CountTotalSigOps(tx *dcrutil.Tx, txType stake.TxType) (uint32, error) { isVote := txType == stake.TxTypeSSGen - isStakeBase := txType == stake.TxTypeSSGen utxoView, err := p.fetchInputUtxos(tx, p.chain.isTreasuryAgendaActive) if err != nil { return 0, err } - sigOps := blockchain.CountSigOps(tx, false, isVote, + return blockchain.CountTotalSigOps(tx, false, isVote, utxoView, p.chain.isTreasuryAgendaActive) - p2shSigOps, err := blockchain.CountP2SHSigOps(tx, false, isStakeBase, - utxoView, p.chain.isTreasuryAgendaActive) - if err != nil { - return 0, err - } - - return sigOps + p2shSigOps, nil } // fetchRedeemers returns all transactions that reference an outpoint for the @@ -508,7 +500,7 @@ func (p *fakeTxSource) removeOrphanDoubleSpends(tx *dcrutil.Tx) { } // addTransaction adds the passed transaction to the fake tx source. -func (p *fakeTxSource) addTransaction(tx *dcrutil.Tx, txType stake.TxType, height int64, fee int64, totalSigOps int) { +func (p *fakeTxSource) addTransaction(tx *dcrutil.Tx, txType stake.TxType, height int64, fee int64, totalSigOps uint32) { // Add the transaction to the pool and mark the referenced outpoints // as spent by the pool. txDesc := TxDesc{ @@ -1299,7 +1291,7 @@ func (m *miningHarness) CreateVote(ticket *dcrutil.Tx, mungers ...func(*wire.Msg // CountTotalSigOps returns the total number of signature operations for the // given transaction. -func (m *miningHarness) CountTotalSigOps(tx *dcrutil.Tx) (int, error) { +func (m *miningHarness) CountTotalSigOps(tx *dcrutil.Tx) (uint32, error) { txType := stake.DetermineTxType(tx.MsgTx()) return m.txSource.CountTotalSigOps(tx, txType) } @@ -1459,7 +1451,7 @@ func newMiningHarness(chainParams *chaincfg.Params) (*miningHarness, []spendable isAutoRevocationsEnabled, subsidySplitVariant) }, CheckTSpendHasVotes: chain.CheckTSpendHasVotes, - CountSigOps: blockchain.CountSigOps, + CountTotalSigOps: blockchain.CountTotalSigOps, FetchUtxoEntry: chain.FetchUtxoEntry, FetchUtxoView: chain.FetchUtxoView, FetchUtxoViewParentTemplate: chain.FetchUtxoViewParentTemplate, diff --git a/internal/mining/mining_view_test.go b/internal/mining/mining_view_test.go index 47651fc195..a9371ffd79 100644 --- a/internal/mining/mining_view_test.go +++ b/internal/mining/mining_view_test.go @@ -94,7 +94,7 @@ func TestMiningView(t *testing.T) { subject *dcrutil.Tx expectedAncestorFees int64 expectedSizeBytes int64 - expectedSigOps int + expectedSigOps uint32 ancestors []*dcrutil.Tx descendants map[chainhash.Hash]*dcrutil.Tx orderedAncestors [][]*dcrutil.Tx diff --git a/server.go b/server.go index 3f823bc93c..5439f135c2 100644 --- a/server.go +++ b/server.go @@ -4308,7 +4308,7 @@ func newServer(ctx context.Context, profiler *profileServer, isAutoRevocationsEnabled, subsidySplitVariant) }, CheckTSpendHasVotes: s.chain.CheckTSpendHasVotes, - CountSigOps: blockchain.CountSigOps, + CountTotalSigOps: blockchain.CountTotalSigOps, FetchUtxoEntry: s.chain.FetchUtxoEntry, FetchUtxoView: s.chain.FetchUtxoView, FetchUtxoViewParentTemplate: s.chain.FetchUtxoViewParentTemplate, From fbf5b0e438d6f045f6aa5902376771af758afb72 Mon Sep 17 00:00:00 2001 From: Dave Collins Date: Wed, 6 May 2026 07:07:33 -0500 Subject: [PATCH 14/19] blockchain: Unsigned treasury spend vote counts. As mentioned in the previous commit, consensus code should ideally always use types of a specific size so there is no possibility of divergent behavior when compiled on different architectures due to differing upper bounds. Further, unsigned types for values that can never be negative should always be preferred. The current code for counting treasury spend votes does not adhere to practice and uses plain signed integers. It is worth noting that this is not an active issue at the moment because the maximum possible counts achievable due to other limits imposed on the max number of votes and the window over which the number of votes are counted are less than the maximum value of the smallest supported architecture (32 bits). Nevertheless, these types of hidden assumptions are fragile and can easily lead to undetected and unexpected behavior when parameters change and new features are introduced. The primary goal of this commit is to address that by modifying the relevant code to use fixed unsigned 32-bit integers instead. While here, it also takes the opportunity to cleanup the code to make it more consistent with the other consensus code. Note that there is no risk of consensus divergence due to the aforementioned impossibility of hitting the conditions with the current combination of parameters and limits. --- internal/blockchain/error.go | 20 ++- internal/blockchain/error_test.go | 4 +- internal/blockchain/treasury.go | 155 +++++++++---------- internal/blockchain/treasury_test.go | 48 +++--- internal/blockchain/validate.go | 2 + internal/rpcserver/interface.go | 2 +- internal/rpcserver/rpcserver.go | 6 +- internal/rpcserver/rpcserverhandlers_test.go | 6 +- 8 files changed, 130 insertions(+), 113 deletions(-) diff --git a/internal/blockchain/error.go b/internal/blockchain/error.go index 5382d360b1..f4c864cb1b 100644 --- a/internal/blockchain/error.go +++ b/internal/blockchain/error.go @@ -477,14 +477,30 @@ const ( // block that is not at a TVI interval. ErrNotTVI = ErrorKind("ErrNotTVI") - // ErrInvalidTSpendWindow indicates that this treasury spend - // transaction is outside of the allowed window. + // ErrInvalidTreasurySpendExpiry indicates that a treasury spend transaction + // has an invalid expiry. + ErrInvalidTreasurySpendExpiry = ErrorKind("ErrInvalidTreasurySpendExpiry") + + // ErrInvalidTSpendWindow indicates that a treasury spend transaction is + // outside of the allowed window. ErrInvalidTSpendWindow = ErrorKind("ErrInvalidTSpendWindow") // ErrNotEnoughTSpendVotes indicates that a treasury spend transaction // does not have enough votes to be included in block. ErrNotEnoughTSpendVotes = ErrorKind("ErrNotEnoughTSpendVotes") + // ErrTooManyTreasurySpendVotes indicates that the number of treasury spend + // votes in a treasury voting window exceeeded maximum allowable number of + // votes. + // + // In practice, this implies there was an unexpected overflow when tallying + // votes since there is not directly an explicit upper bound on the allowed + // votes. Rather, the upper bound is implicit due to the size of the voting + // window and the maximum number of allowed stake votes per block. + // + // This error is not possible to hit at the time this comment was written. + ErrTooManyTreasurySpendVotes = ErrorKind("ErrTooManyTreasurySpendVotes") + // ErrInvalidTSpendValueIn indicates that a treasury spend transaction // ValueIn does not match the encoded copy in the first TxOut. ErrInvalidTSpendValueIn = ErrorKind("ErrInvalidTSpendValueIn") diff --git a/internal/blockchain/error_test.go b/internal/blockchain/error_test.go index 2262e06edb..4dcd630a88 100644 --- a/internal/blockchain/error_test.go +++ b/internal/blockchain/error_test.go @@ -1,5 +1,5 @@ // Copyright (c) 2014 The btcsuite developers -// Copyright (c) 2015-2023 The Decred developers +// Copyright (c) 2015-2026 The Decred developers // Use of this source code is governed by an ISC // license that can be found in the LICENSE file. @@ -121,8 +121,10 @@ func TestErrorKindStringer(t *testing.T) { {ErrInvalidPiSignature, "ErrInvalidPiSignature"}, {ErrInvalidTVoteWindow, "ErrInvalidTVoteWindow"}, {ErrNotTVI, "ErrNotTVI"}, + {ErrInvalidTreasurySpendExpiry, "ErrInvalidTreasurySpendExpiry"}, {ErrInvalidTSpendWindow, "ErrInvalidTSpendWindow"}, {ErrNotEnoughTSpendVotes, "ErrNotEnoughTSpendVotes"}, + {ErrTooManyTreasurySpendVotes, "ErrTooManyTreasurySpendVotes"}, {ErrInvalidTSpendValueIn, "ErrInvalidTSpendValueIn"}, {ErrTSpendExists, "ErrTSpendExists"}, {ErrInvalidExpenditure, "ErrInvalidExpenditure"}, diff --git a/internal/blockchain/treasury.go b/internal/blockchain/treasury.go index 091b595ea9..3c3e7e15dc 100644 --- a/internal/blockchain/treasury.go +++ b/internal/blockchain/treasury.go @@ -1,4 +1,4 @@ -// Copyright (c) 2020-2025 The Decred developers +// Copyright (c) 2020-2026 The Decred developers // Use of this source code is governed by an ISC // license that can be found in the LICENSE file. @@ -976,104 +976,105 @@ func (b *BlockChain) CheckTSpendExists(prevHash, tspend chainhash.Hash) error { return b.checkTSpendExists(prevNode, tspend) } -// getVotes returns yes and no votes for the provided hash. -func getVotes(votes []stake.TreasuryVoteTuple, hash *chainhash.Hash) (yes int, no int) { - if votes == nil { - return - } - - for _, v := range votes { - if !hash.IsEqual(&v.Hash) { - continue - } - - switch v.Vote { - case stake.TreasuryVoteYes: - yes++ - case stake.TreasuryVoteNo: - no++ - default: - // Can't happen. - trsyLog.Criticalf("getVotes: invalid vote 0x%v", v.Vote) - } - } - - return -} - // tspendVotes is a structure that contains a treasury vote tally for a given // window. type tspendVotes struct { start uint32 // Start block end uint32 // End block - yes int // Yes vote tally - no int // No vote tally + yes uint32 // Yes vote tally + no uint32 // No vote tally } -// tSpendCountVotes returns the vote tally for a given tspend up to the -// specified block. Note that this function errors if the block is outside the -// voting window for the given tspend. +// tSpendCountVotes returns the total yes and no vote counts for a given +// treasury spend up to the specified block. +// +// An error is returned if the block is outside the voting window for the given +// treasury spend. +// +// This function is safe for concurrent access. func (b *BlockChain) tSpendCountVotes(prevNode *blockNode, tspend *dcrutil.Tx) (*tspendVotes, error) { - var ( - t tspendVotes - err error - ) + // Shorter version of various parameter for convenience. + tvi := b.chainParams.TreasuryVoteInterval + tvim := b.chainParams.TreasuryVoteIntervalMultiplier expiry := tspend.MsgTx().Expiry - t.start, t.end, err = standalone.CalcTSpendWindow(expiry, - b.chainParams.TreasuryVoteInterval, - b.chainParams.TreasuryVoteIntervalMultiplier) + start, end, err := standalone.CalcTSpendWindow(expiry, tvi, tvim) if err != nil { - return nil, err + return nil, standaloneToChainRuleError(err) } + trsySpendHash := tspend.Hash() nextHeight := prevNode.height + 1 trsyLog.Tracef("Counting votes for treasury spend %s (height %d, voting "+ - "window [%d, %d], expiry %d)", tspend.Hash(), nextHeight, t.start, - t.end, expiry) - - // Ensure tspend is within the window. - if !standalone.InsideTSpendWindow(nextHeight, - expiry, b.chainParams.TreasuryVoteInterval, - b.chainParams.TreasuryVoteIntervalMultiplier) { - err = fmt.Errorf("tspend outside of window: nextHeight %v "+ - "start %v expiry %v", nextHeight, t.start, expiry) - return nil, err - } - - node := prevNode - for { - if node.height < int64(t.start) { - break - } - - // Find SSGen and peel out votes. - var xblock *dcrutil.Block - xblock, err = b.fetchBlockByNode(node) + "window [%d, %d], expiry %d)", trsySpendHash, nextHeight, start, end, + expiry) + + // Ensure the treasury spend is within its valid voting window. + if !standalone.InsideTSpendWindow(nextHeight, expiry, tvi, tvim) { + str := fmt.Sprintf("treasury spend %v at height %d with expiry %d is "+ + "outside of the valid window [%d, %d]", trsySpendHash, nextHeight, + expiry, start, end) + return nil, ruleError(ErrInvalidTSpendWindow, str) + } + + // Tally the total number of yes and no votes in the voting window. + var totalYes, totalNo uint32 + for n := prevNode; n != nil && n.height >= int64(start); n = n.parent { + // Find stake votes and extract treasury spend votes. + var ok bool + var block *dcrutil.Block + block, err = b.fetchBlockByNode(n) if err != nil { // Should not happen. return nil, err } - for _, v := range xblock.STransactions() { - votes, err := stake.CheckSSGenVotes(v.MsgTx()) + for _, stx := range block.MsgBlock().STransactions { + // Nothing to do for transactions that are not stake votes or do not + // contain any treasury spend votes. + votes, err := stake.CheckSSGenVotes(stx) if err != nil { - // Not an SSGEN + // Not a stake vote. + continue + } + if len(votes) == 0 { continue } - // Find our vote bits. - yes, no := getVotes(votes, tspend.Hash()) - t.yes += yes - t.no += no - } + // Find and tally votes for the treasury spend hash. + for _, v := range votes { + if *trsySpendHash != v.Hash { + continue + } - node = node.parent - if node == nil { - break + switch v.Vote { + case stake.TreasuryVoteYes: + totalYes, ok = addUnsigned(totalYes, 1) + if !ok { + str := fmt.Sprintf("yes vote for treasury spend %v at "+ + "height %d causes yes count to overflow", + trsySpendHash, n.height) + return nil, ruleError(ErrTooManyTreasurySpendVotes, str) + } + + case stake.TreasuryVoteNo: + totalNo, ok = addUnsigned(totalNo, 1) + if !ok { + str := fmt.Sprintf("no vote for treasury spend %v at "+ + "height %d causes no count to overflow", + trsySpendHash, n.height) + return nil, ruleError(ErrTooManyTreasurySpendVotes, str) + } + } + } } } - return &t, nil + return &tspendVotes{ + start: start, + end: end, + yes: totalYes, + no: totalNo, + }, nil } // TSpendCountVotes tallies the votes given for the specified tspend during its @@ -1086,20 +1087,18 @@ func (b *BlockChain) tSpendCountVotes(prevNode *blockNode, tspend *dcrutil.Tx) ( // the next block is outside the voting interval. // // This function is safe for concurrent access. -func (b *BlockChain) TSpendCountVotes(blockHash *chainhash.Hash, tspend *dcrutil.Tx) (yesVotes, noVotes int64, err error) { - b.index.RLock() - defer b.index.RUnlock() - - prevNode := b.index.lookupNode(blockHash) +func (b *BlockChain) TSpendCountVotes(blockHash *chainhash.Hash, tspend *dcrutil.Tx) (yesVotes, noVotes uint32, err error) { + prevNode := b.index.LookupNode(blockHash) if prevNode == nil { return 0, 0, unknownBlockError(blockHash) } + tv, err := b.tSpendCountVotes(prevNode, tspend) if err != nil { return 0, 0, err } - return int64(tv.yes), int64(tv.no), nil + return tv.yes, tv.no, nil } // checkTSpendHasVotes verifies that the provided TSpend has enough votes to be diff --git a/internal/blockchain/treasury_test.go b/internal/blockchain/treasury_test.go index e296bca3c9..2381053a94 100644 --- a/internal/blockchain/treasury_test.go +++ b/internal/blockchain/treasury_test.go @@ -505,13 +505,13 @@ func TestTSpendVoteCount(t *testing.T) { t.Fatalf("invalid end block got %v wanted %v", tv.end, end) } - expectedYesVotes := 0 // We voted a bunch of times outside the window + const expectedYesVotes = 0 // We voted a bunch of times outside the window expectedNoVotes := tvi * mul * uint64(params.TicketsPerBlock) - if expectedYesVotes != tv.yes { - t.Fatalf("invalid yes votes got %v wanted %v", expectedYesVotes, tv.yes) + if tv.yes != expectedYesVotes { + t.Fatalf("invalid yes votes got %v wanted %v", tv.yes, expectedYesVotes) } - if expectedNoVotes != uint64(tv.no) { - t.Fatalf("invalid no votes got %v wanted %v", expectedNoVotes, tv.no) + if uint64(tv.no) != expectedNoVotes { + t.Fatalf("invalid no votes got %v wanted %v", tv.no, expectedNoVotes) } // --------------------------------------------------------------------- @@ -614,9 +614,8 @@ func TestTSpendVoteCount(t *testing.T) { if err != nil { t.Fatal(err) } - if int(quorum-1) != tv.yes { - t.Fatalf("unexpected yesVote count got %v wanted %v", - tv.yes, quorum-1) + if uint64(tv.yes) != quorum-1 { + t.Fatalf("unexpected yesVote count got %v wanted %v", tv.yes, quorum-1) } // Hit exact yes vote quorum @@ -647,9 +646,8 @@ func TestTSpendVoteCount(t *testing.T) { if err != nil { t.Fatal(err) } - if int(quorum) != tv.yes { - t.Fatalf("unexpected yesVote count got %v wanted %v", - tv.yes, quorum) + if uint64(tv.yes) != quorum { + t.Fatalf("unexpected yesVote count got %v wanted %v", tv.yes, quorum) } // Verify TSpend can be added exactly on quorum. @@ -2853,13 +2851,13 @@ func TestTSpendVoteCountSynthetic(t *testing.T) { numNodes int64 set func(*BlockChain, *blockNode) blockVersion int32 - expectedYesVotes int - expectedNoVotes int + expectedYesVotes uint32 + expectedNoVotes uint32 failure bool }{{ name: "All yes", numNodes: int64(end), - expectedYesVotes: int(tpb) * int(tvi*mul), + expectedYesVotes: uint32(tpb) * uint32(tvi*mul), expectedNoVotes: 0, failure: false, set: func(bc *BlockChain, node *blockNode) { @@ -2887,7 +2885,7 @@ func TestTSpendVoteCountSynthetic(t *testing.T) { name: "All no", numNodes: int64(end), expectedYesVotes: 0, - expectedNoVotes: int(tpb) * int(tvi*mul), + expectedNoVotes: uint32(tpb) * uint32(tvi*mul), failure: true, set: func(bc *BlockChain, node *blockNode) { // Create block. @@ -2979,7 +2977,7 @@ func TestTSpendVoteCountSynthetic(t *testing.T) { }, { name: "All yes quorum", numNodes: int64(end), - expectedYesVotes: int(quorum), + expectedYesVotes: uint32(quorum), expectedNoVotes: 0, failure: false, set: func(bc *BlockChain, node *blockNode) { @@ -3010,7 +3008,7 @@ func TestTSpendVoteCountSynthetic(t *testing.T) { }, { name: "All yes quorum - 1", numNodes: int64(end), - expectedYesVotes: int(quorum - 1), + expectedYesVotes: uint32(quorum - 1), expectedNoVotes: 0, failure: true, set: func(bc *BlockChain, node *blockNode) { @@ -3041,7 +3039,7 @@ func TestTSpendVoteCountSynthetic(t *testing.T) { }, { name: "All yes quorum + 1", numNodes: int64(end), - expectedYesVotes: int(quorum + 1), + expectedYesVotes: uint32(quorum + 1), expectedNoVotes: 0, failure: false, set: func(bc *BlockChain, node *blockNode) { @@ -3072,8 +3070,8 @@ func TestTSpendVoteCountSynthetic(t *testing.T) { }, { name: "Exactly yes required", numNodes: int64(end), - expectedYesVotes: int(requiredVotes), - expectedNoVotes: int(maxVotes) - int(requiredVotes), + expectedYesVotes: uint32(requiredVotes), + expectedNoVotes: maxVotes - uint32(requiredVotes), failure: false, set: func(bc *BlockChain, node *blockNode) { // Create block. @@ -3105,8 +3103,8 @@ func TestTSpendVoteCountSynthetic(t *testing.T) { }, { name: "Yes required - 1", numNodes: int64(end), - expectedYesVotes: int(requiredVotes - 1), - expectedNoVotes: int(maxVotes) - int(requiredVotes-1), + expectedYesVotes: uint32(requiredVotes - 1), + expectedNoVotes: maxVotes - uint32(requiredVotes-1), failure: true, set: func(bc *BlockChain, node *blockNode) { // Create block. @@ -3138,8 +3136,8 @@ func TestTSpendVoteCountSynthetic(t *testing.T) { }, { name: "Yes required + 1", numNodes: int64(end), - expectedYesVotes: int(requiredVotes + 1), - expectedNoVotes: int(maxVotes) - int(requiredVotes+1), + expectedYesVotes: uint32(requiredVotes + 1), + expectedNoVotes: maxVotes - uint32(requiredVotes+1), failure: false, set: func(bc *BlockChain, node *blockNode) { // Create block. @@ -3171,7 +3169,7 @@ func TestTSpendVoteCountSynthetic(t *testing.T) { }, { name: "Exactly yes required with abstain", numNodes: int64(end), - expectedYesVotes: int(requiredVotes), + expectedYesVotes: uint32(requiredVotes), expectedNoVotes: 0, failure: false, set: func(bc *BlockChain, node *blockNode) { diff --git a/internal/blockchain/validate.go b/internal/blockchain/validate.go index 31e52a7913..cef3849de4 100644 --- a/internal/blockchain/validate.go +++ b/internal/blockchain/validate.go @@ -690,6 +690,8 @@ func standaloneToChainRuleError(err error) error { return ruleError(ErrFraudAmountIn, err.Error()) case errors.Is(err, standalone.ErrDuplicateTxInputs): return ruleError(ErrDuplicateTxInputs, err.Error()) + case errors.Is(err, standalone.ErrInvalidTSpendExpiry): + return ruleError(ErrInvalidTreasurySpendExpiry, err.Error()) } return err diff --git a/internal/rpcserver/interface.go b/internal/rpcserver/interface.go index 4a078f1812..97b88fde9a 100644 --- a/internal/rpcserver/interface.go +++ b/internal/rpcserver/interface.go @@ -440,7 +440,7 @@ type Chain interface { // TSpendCountVotes returns the votes for the specified tspend up to // the specified block. - TSpendCountVotes(*chainhash.Hash, *dcrutil.Tx) (int64, int64, error) + TSpendCountVotes(*chainhash.Hash, *dcrutil.Tx) (uint32, uint32, error) // InvalidateBlock manually invalidates the provided block as if the block // had violated a consensus rule and marks all of its descendants as having diff --git a/internal/rpcserver/rpcserver.go b/internal/rpcserver/rpcserver.go index da4e0c1ddc..f4907bdd83 100644 --- a/internal/rpcserver/rpcserver.go +++ b/internal/rpcserver/rpcserver.go @@ -3437,7 +3437,7 @@ func handleGetTreasurySpendVotes(_ context.Context, s *Server, cmd any) (any, er // We only count votes for tspends that are inside their voting // window. Otherwise we just return the appropriate vote start // and end heights for it. - var yes, no int64 + var yes, no uint32 insideWindow := standalone.InsideTSpendWindow(blockHeight, expiry, tvi, mul) minedBlock, isMined := endBlocks[*txHash] if insideWindow || isMined { @@ -3470,8 +3470,8 @@ func handleGetTreasurySpendVotes(_ context.Context, s *Server, cmd any) (any, er Expiry: int64(expiry), VoteStart: int64(start), VoteEnd: int64(end), - YesVotes: yes, - NoVotes: no, + YesVotes: int64(yes), + NoVotes: int64(no), } } diff --git a/internal/rpcserver/rpcserverhandlers_test.go b/internal/rpcserver/rpcserverhandlers_test.go index 562282735a..ac6d8fd3be 100644 --- a/internal/rpcserver/rpcserverhandlers_test.go +++ b/internal/rpcserver/rpcserverhandlers_test.go @@ -127,8 +127,8 @@ func (u *testRPCUtxoEntry) TicketMinimalOutputs() []*stake.MinimalOutput { // tspendVotes is used to mock the results of a chain TSpendCountVotes call. type tspendVotes struct { - yes int64 - no int64 + yes uint32 + no uint32 err error } @@ -434,7 +434,7 @@ func (c *testRPCChain) FetchTSpend(chainhash.Hash) ([]chainhash.Hash, error) { // TSpendCountVotes counts the number of votes a given tspend has received up // to the given block. -func (c *testRPCChain) TSpendCountVotes(*chainhash.Hash, *dcrutil.Tx) (int64, int64, error) { +func (c *testRPCChain) TSpendCountVotes(*chainhash.Hash, *dcrutil.Tx) (uint32, uint32, error) { return c.tspendVotes.yes, c.tspendVotes.no, c.tspendVotes.err } From 8cbae7f53eeecfc1e8d2041ec19a8ca26bf6d632 Mon Sep 17 00:00:00 2001 From: Dave Collins Date: Wed, 6 May 2026 07:07:34 -0500 Subject: [PATCH 15/19] blockchain: Enforce trsybase fee in inputs check. The requirement that treasurybases have zero fee is currently indirectly enforced by ensuring the input sum is the required subsidy and that the total of all fees paid in the stake tree do not exceed the input sum. While that doesn't cause any real issues, it's not very clear and it ideally should be done in the per-transaction input checks so it is consistent with all other transaction types. This modifes CheckTransactionInputs to apply the additional input versus output sum check to apply to treasurybase transactions as well. --- internal/blockchain/treasury_test.go | 2 +- internal/blockchain/validate.go | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/internal/blockchain/treasury_test.go b/internal/blockchain/treasury_test.go index 2381053a94..e80179e302 100644 --- a/internal/blockchain/treasury_test.go +++ b/internal/blockchain/treasury_test.go @@ -2342,7 +2342,7 @@ func TestTreasuryBaseCorners(t *testing.T) { // corruptTreasurybaseValueIn is a munge function which modifies the // provided block by mutating the treasurybase in value. corruptTreasurybaseValueIn := func(b *wire.MsgBlock) { - b.STransactions[0].TxIn[0].ValueIn-- + b.STransactions[0].TxIn[0].ValueIn++ } // corruptTreasurybaseValueOut is a munge function which modifies the diff --git a/internal/blockchain/validate.go b/internal/blockchain/validate.go index cef3849de4..8b0c26d17d 100644 --- a/internal/blockchain/validate.go +++ b/internal/blockchain/validate.go @@ -3110,14 +3110,13 @@ func CheckTransactionInputs(subsidyCache *standalone.SubsidyCache, isTreasuryEnabled, isAutoRevocationsEnabled bool, subsidySplitVariant standalone.SubsidySplitVariant) (int64, error) { - // Coinbase transactions have no inputs. + // NOTE: This check MUST come before the coinbase check because a + // treasurybase will be identified as a coinbase as well. msgTx := tx.MsgTx() - if standalone.IsCoinBaseTx(msgTx, isTreasuryEnabled) { - return 0, nil - } + isTreasuryBase := isTreasuryEnabled && standalone.IsTreasuryBase(msgTx) - // Treasurybase transactions have no inputs. - if isTreasuryEnabled && standalone.IsTreasuryBase(msgTx) { + // Coinbase transactions have no inputs. + if !isTreasuryBase && standalone.IsCoinBaseTx(msgTx, isTreasuryEnabled) { return 0, nil } @@ -3219,7 +3218,7 @@ func CheckTransactionInputs(subsidyCache *standalone.SubsidyCache, } // idx can only be 0 in this case but check it anyway. - if isTSpend && idx == 0 { + if (isTreasuryBase || isTSpend) && idx == 0 { totalAtomIn += txIn.ValueIn continue } From e0500e5d155c856a2f05c77e39f332c2ce385808 Mon Sep 17 00:00:00 2001 From: Dave Collins Date: Wed, 6 May 2026 07:07:35 -0500 Subject: [PATCH 16/19] blockchain: Enforce trsy spend amt in input checks. Currently, enforcement that treasury spends commit to the amount they are spending in the first output and that the amount matches the value specified as the input amount in the first input happen when blocks are connected. The check is not dependent on the current treasury balance or anything that would require it to be limited to block connection only. It should ideally be in the per-transaction input checks so that it is also enforced early when a treasury spend is added to the mempool. To accomplish that, this moves that check, along with a couple of other additional sanity checks, into a separate method dedicated to checking treasury spend inputs so that it is more consistent with the other stake transaction type handling and invokes that method from CheckTransactionInputs. It also moves the other treasury spend checks after the call that checks and connects transactions in the stake tree to ensure the commitment is still verified prior to the other checks that depend on it. --- internal/blockchain/validate.go | 121 ++++++++++++++++++++++++-------- 1 file changed, 92 insertions(+), 29 deletions(-) diff --git a/internal/blockchain/validate.go b/internal/blockchain/validate.go index 8b0c26d17d..0ec6e33ba7 100644 --- a/internal/blockchain/validate.go +++ b/internal/blockchain/validate.go @@ -3092,6 +3092,68 @@ func checkRevocationInputs(tx *dcrutil.Tx, txHeight int64, view *UtxoViewpoint, false, 0, prevHeader, isTreasuryEnabled, isAutoRevocationsEnabled) } +// extractTreasurySpendCommitAmount extracts and decodes the amount from a +// treasury spend output commitment script. +// +// NOTE: The caller MUST have already determined that the provided script is the +// script from the first output of a treasury spend and that the script is well +// formed as required or the function may panic. +func extractTreasurySpendCommitAmount(script []byte) int64 { + // A treasury spend commitment output is an OP_RETURN script with a 32-byte + // data push that consists of an 8-byte little-endian amount to commit to + // and a 24-byte random value. Thus, 1 byte for the OP_RETURN + 1 byte for + // the data push means the encoded amount is at offset 2. + const startIdx = 2 + const endIdx = startIdx + 8 + encodedValue := script[startIdx:endIdx] + return int64(binary.LittleEndian.Uint64(encodedValue)) +} + +// checkTreasurySpendInputs performs a series of checks on the inputs to a +// treasury spend transaction. An example of some of the checks include +// verifying the input values are sane and the spend amount commitment in the +// first output matches the input amount. +// +// NOTE: The caller MUST have already determined that the provided transaction +// is a treasury spend or the function may panic. +func checkTreasurySpendInputs(msgTx *wire.MsgTx) error { + // Assert there is at least one input and one output. + if len(msgTx.TxIn) < 1 || len(msgTx.TxOut) < 1 { + panicf("attempt to check treasury spend inputs on tx %s which does "+ + "not appear to be a treasury spend (%d inputs, %d outputs)", + msgTx.TxHash(), len(msgTx.TxIn), len(msgTx.TxOut)) + } + + // Ensure all input amounts are sane. This is only a fast check for + // obviously invalid values. The expenditure policy is enforced separately. + for _, txIn := range msgTx.TxIn { + valueIn := txIn.ValueIn + if valueIn < 0 { + str := fmt.Sprintf("treasury spend has negative value of %v", + valueIn) + return ruleError(ErrBadTxInput, str) + } + if valueIn > dcrutil.MaxAmount { + str := fmt.Sprintf("treasury spend value of %v is higher than "+ + "max allowed value of %v", valueIn, dcrutil.MaxAmount) + return ruleError(ErrBadTxInput, str) + } + } + + // A valid treasury spend must specify the entire amount that the treasury + // is spending in the first input and commit to that amount in the script of + // the first output. + valueIn := msgTx.TxIn[0].ValueIn + commitmentAmt := extractTreasurySpendCommitAmount(msgTx.TxOut[0].PkScript) + if valueIn != commitmentAmt { + str := fmt.Sprintf("treasury spend input value %v does not match "+ + "spend amount commitment %v", valueIn, commitmentAmt) + return ruleError(ErrInvalidTSpendValueIn, str) + } + + return nil +} + // CheckTransactionInputs performs a series of checks on the inputs to a // transaction to ensure they are valid. An example of some of the checks // include verifying all inputs exist, ensuring the coinbase seasoning @@ -3177,11 +3239,11 @@ func CheckTransactionInputs(subsidyCache *standalone.SubsidyCache, // they have a valid signature from a sanctioned key. // // Also keep track of whether or not it is a treasury spend for later. - var isTSpend bool + var isTreasurySpend bool if isTreasuryEnabled { signature, pubKey, err := stake.CheckTSpend(msgTx) - isTSpend = err == nil - if isTSpend { + isTreasurySpend = err == nil + if isTreasurySpend { // The public key used to sign the treasury spend must be one of the // sanctioned pi keys. if !chainParams.PiKeyExists(pubKey) { @@ -3200,6 +3262,16 @@ func CheckTransactionInputs(subsidyCache *standalone.SubsidyCache, } } + // Perform additional checks on treasury spends such as verifying the input + // values are sane and the spend amount commitment in the first output + // matches the input amount. + if isTreasurySpend { + err := checkTreasurySpendInputs(tx.MsgTx()) + if err != nil { + return 0, err + } + } + // ------------------------------------------------------------------- // Decred general transaction testing (and a few stake exceptions). // ------------------------------------------------------------------- @@ -3218,7 +3290,7 @@ func CheckTransactionInputs(subsidyCache *standalone.SubsidyCache, } // idx can only be 0 in this case but check it anyway. - if (isTreasuryBase || isTSpend) && idx == 0 { + if (isTreasuryBase || isTreasurySpend) && idx == 0 { totalAtomIn += txIn.ValueIn continue } @@ -3977,6 +4049,9 @@ func (b *BlockChain) consensusScriptVerifyFlags(node *blockNode) (txscript.Scrip // block. It verifies that it is on a TVI, is within the correct window, has // not been mined before and that it doesn't overspend the treasury. This // function assumes that the treasury agenda is enabled. +// +// The caller MUST have already have already called [checkTreasurySpendInputs] +// on all treasury spends in the block and prior to calling this method. func (b *BlockChain) tspendChecks(prevNode *blockNode, block *dcrutil.Block) error { blockHeight := prevNode.height + 1 isTVI := standalone.IsTreasuryVoteInterval(uint64(blockHeight), @@ -4007,25 +4082,12 @@ func (b *BlockChain) tspendChecks(prevNode *blockNode, block *dcrutil.Block) err return ruleError(ErrInvalidTSpendWindow, str) } - // A valid TSPEND always stores the entire amount that the - // treasury is spending in the first TxIn. + // A valid treasury spend always stores the entire amount that the + // treasury is spending in the first input. It is safe to use since it + // has already been verified to match the commitment value. valueIn := stx.MsgTx().TxIn[0].ValueIn totalTSpendAmount += valueIn - // Verify that the ValueIn amount is identical to the LE - // encoded ValueIn in the OP_RETURN. Since the TSpend has been - // validated to be correct we simply index the bytes directly - // without additional checks. - leValueIn := stx.MsgTx().TxOut[0].PkScript[2 : 2+8] - valueInOpRet := int64(binary.LittleEndian.Uint64(leValueIn)) - if valueIn != valueInOpRet { - str := fmt.Sprintf("block contains TSpend transaction "+ - "(%v) that did not encode ValueIn correctly "+ - "got %v wanted %v", stx.Hash(), valueInOpRet, - valueIn) - return ruleError(ErrInvalidTSpendValueIn, str) - } - // Verify this TSpend hash has not been included in a // prior block. err := b.checkTSpendExists(prevNode, *stx.Hash()) @@ -4116,15 +4178,6 @@ func (b *BlockChain) checkConnectBlock(node *blockNode, block, parent *dcrutil.B } } - // Verify treasury spends. This is done relatively late because the - // database needs to be coherent. - if isTreasuryEnabled { - err = b.tspendChecks(node.parent, block) - if err != nil { - return err - } - } - // Don't run scripts if this node is both an ancestor of the assumed valid // block and an ancestor of the best header since the validity is verified // via the assumed valid node (all transactions are included in the merkle @@ -4201,6 +4254,16 @@ func (b *BlockChain) checkConnectBlock(node *blockNode, block, parent *dcrutil.B return err } + // Verify treasury spends. This is done relatively late because the + // database needs to be coherent and it depends on input checks already + // being done. + if isTreasuryEnabled { + err = b.tspendChecks(node.parent, block) + if err != nil { + return err + } + } + stakeTreeFees, err := getStakeTreeFees(b.subsidyCache, node.height, block.STransactions(), view, isTreasuryEnabled, subsidySplitVariant) if err != nil { From 6bd4d97553b93da576475cc99b6249137c97742e Mon Sep 17 00:00:00 2001 From: Dave Collins Date: Wed, 6 May 2026 07:07:35 -0500 Subject: [PATCH 17/19] blockchain: Add a couple of trsybase corner tests. This adds a couple new tests to help ensure the treasurybase overall amount sum semantics are correct. --- internal/blockchain/treasury_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/internal/blockchain/treasury_test.go b/internal/blockchain/treasury_test.go index e80179e302..db309a5eef 100644 --- a/internal/blockchain/treasury_test.go +++ b/internal/blockchain/treasury_test.go @@ -2430,6 +2430,28 @@ func TestTreasuryBaseCorners(t *testing.T) { g.NextBlock("invalidout0", nil, outs[1:], corruptTreasurybaseValueOut) g.RejectTipBlock(ErrTreasurybaseOutValue) + // ------------------------------------------------------------------------- + // Treasury base spends more than input amount. + // ------------------------------------------------------------------------- + + g.SetTip(startTip) + g.NextBlock("invalidout1", nil, outs[1:], func(b *wire.MsgBlock) { + b.STransactions[0].TxOut[1].Value++ + }) + g.RejectTipBlock(ErrSpendTooHigh) + + // ------------------------------------------------------------------------- + // Treasury base spends the correct amount overall, but does not give the + // correct amount to the treasury. + // ------------------------------------------------------------------------- + + g.SetTip(startTip) + g.NextBlock("invalidout2", nil, outs[1:], func(b *wire.MsgBlock) { + b.STransactions[0].TxOut[0].Value-- + b.STransactions[0].TxOut[1].Value++ + }) + g.RejectTipBlock(ErrTreasurybaseOutValue) + // Note we can't hit the following errors in consensus: // * ErrFirstTxNotTreasurybase (missing OP_RETURN) // * ErrFirstTxNotTreasurybase (version) From dda9c9b76fc92dca4ade705f554af35d31dbb7a4 Mon Sep 17 00:00:00 2001 From: Dave Collins Date: Wed, 6 May 2026 07:07:36 -0500 Subject: [PATCH 18/19] blockchain: Don't recalculate stake tree fees. The current code recalculates the total stake tree fees via a separate getStakeTreeFees function in order to pass them to the regular tree transaction and connection checks so they are accumulated as part of the total overall fees. This behavior is correct, but the total fees are already calculated when checking and connecting the stake tree transactions just before that, so there is no reason to calculate them again when they can simply be returned to the caller. This modifies checkTransactionsAndConnect accordingly and removes the separate method which is no longer necessary. --- internal/blockchain/validate.go | 127 +++++++------------------------- 1 file changed, 26 insertions(+), 101 deletions(-) diff --git a/internal/blockchain/validate.go b/internal/blockchain/validate.go index 0ec6e33ba7..e16b679c43 100644 --- a/internal/blockchain/validate.go +++ b/internal/blockchain/validate.go @@ -3710,96 +3710,28 @@ func getStakeBaseAmounts(txs []*dcrutil.Tx, view *UtxoViewpoint) (int64, error) return totalOutputs - totalInputs, nil } -// getStakeTreeFees determines the amount of fees for in the stake tx tree of -// some node given a utxo view. -func getStakeTreeFees(subsidyCache *standalone.SubsidyCache, height int64, - txs []*dcrutil.Tx, view *UtxoViewpoint, isTreasuryEnabled bool, - subsidySplitVariant standalone.SubsidySplitVariant) (dcrutil.Amount, error) { - - totalInputs := int64(0) - totalOutputs := int64(0) - for _, tx := range txs { - msgTx := tx.MsgTx() - isSSGen := stake.IsSSGen(msgTx) - isTreasuryBase := isTreasuryEnabled && stake.IsTreasuryBase(msgTx) - isTreasurySpend := isTreasuryEnabled && stake.IsTSpend(msgTx) - - for i, in := range msgTx.TxIn { - // Ignore stakebases. - if isSSGen && i == 0 { - continue - } - - // Ignore treasury spends and treasurybases since they have no - // inputs. - if isTreasuryBase || isTreasurySpend { - continue - } - - txInOutpoint := in.PreviousOutPoint - txInHash := &txInOutpoint.Hash - utxoEntry, exists := view.entries[txInOutpoint] - if !exists || utxoEntry == nil { - str := fmt.Sprintf("couldn't find input tx "+ - "%v for stake tree fee calculation", - txInHash) - return 0, ruleError(ErrTicketUnavailable, str) - } - - originTxAtom := utxoEntry.Amount() - - totalInputs += originTxAtom - } - - for _, out := range msgTx.TxOut { - totalOutputs += out.Value - } - - // For votes, subtract the subsidy to determine actual fees. - if isSSGen { - // Subsidy aligns with the height we're voting on, not with the - // height of the current block. - totalOutputs -= subsidyCache.CalcStakeVoteSubsidyV3(height-1, - subsidySplitVariant) - } - - if isTreasurySpend { - totalOutputs -= msgTx.TxIn[0].ValueIn - } - - if isTreasuryBase { - totalOutputs -= msgTx.TxIn[0].ValueIn - } - } - - if totalInputs < totalOutputs { - str := fmt.Sprintf("negative cumulative fees found in stake " + - "tx tree") - return 0, ruleError(ErrStakeFees, str) - } - - return dcrutil.Amount(totalInputs - totalOutputs), nil -} - // checkTransactionsAndConnect is the local function used to check the // transaction inputs for a transaction list given a predetermined utxo view. // After ensuring the transaction is valid, the transaction is connected to the // utxo view. +// +// It returns the total fees paid by the transactions or 0 when the error is not +// nil. func (b *BlockChain) checkTransactionsAndConnect(inputFees dcrutil.Amount, node *blockNode, txs []*dcrutil.Tx, view *UtxoViewpoint, stxos *[]spentTxOut, stakeTree bool, - subsidySplitVariant standalone.SubsidySplitVariant) error { + subsidySplitVariant standalone.SubsidySplitVariant) (dcrutil.Amount, error) { isTreasuryEnabled, err := b.isTreasuryAgendaActive(node.parent) if err != nil { - return err + return 0, err } // Determine if the automatic ticket revocations agenda is active as of the // block being checked. isAutoRevocationsEnabled, err := b.isAutoRevocationsAgendaActive(node.parent) if err != nil { - return err + return 0, err } // Perform several checks on the inputs for each transaction. Also @@ -3830,18 +3762,18 @@ func (b *BlockChain) checkTransactionsAndConnect(inputFees dcrutil.Amount, numSigOps, err := CountTotalSigOps(tx, isCoinBase, isVote, view, isTreasuryEnabled) if err != nil { - return err + return 0, err } cumulativeSigOps, ok = addUnsigned(cumulativeSigOps, numSigOps) if !ok { str := fmt.Sprintf("tx %v causes block signature operation count "+ "to overflow", tx.Hash()) - return ruleError(ErrTooManySigOps, str) + return 0, ruleError(ErrTooManySigOps, str) } if cumulativeSigOps > MaxSigOpsPerBlock { str := fmt.Sprintf("block contains too many signature operations "+ "- got %v, max %v", cumulativeSigOps, MaxSigOpsPerBlock) - return ruleError(ErrTooManySigOps, str) + return 0, ruleError(ErrTooManySigOps, str) } // Perform a series of checks on the inputs to the transaction to ensure @@ -3859,7 +3791,7 @@ func (b *BlockChain) checkTransactionsAndConnect(inputFees dcrutil.Amount, isTreasuryEnabled, isAutoRevocationsEnabled, subsidySplitVariant) if err != nil { log.Tracef("CheckTransactionInputs failed; error returned: %v", err) - return err + return 0, err } // Sum the total fees and ensure we don't overflow the @@ -3867,7 +3799,7 @@ func (b *BlockChain) checkTransactionsAndConnect(inputFees dcrutil.Amount, lastTotalFees := totalFees totalFees += txFee if totalFees < lastTotalFees { - return ruleError(ErrBadFees, "total fees for block "+ + return 0, ruleError(ErrBadFees, "total fees for block "+ "overflows accumulator") } @@ -3885,13 +3817,13 @@ func (b *BlockChain) checkTransactionsAndConnect(inputFees dcrutil.Amount, err := view.connectRegularTransaction(tx, node.height, uint32(idx), inFlightRegularTx, stxos, isTreasuryEnabled) if err != nil { - return err + return 0, err } } else { err := view.connectStakeTransaction(tx, node.height, uint32(idx), stxos, isTreasuryEnabled) if err != nil { - return err + return 0, err } } } @@ -3939,7 +3871,7 @@ func (b *BlockChain) checkTransactionsAndConnect(inputFees dcrutil.Amount, errStr := fmt.Sprintf("bad coinbase subsidy in input;"+ " got %v, expected %v", coinbaseIn.ValueIn, subsidyWithoutFees) - return ruleError(ErrBadCoinbaseAmountIn, errStr) + return 0, ruleError(ErrBadCoinbaseAmountIn, errStr) } if totalAtomOutRegular > expAtomOut { @@ -3947,7 +3879,7 @@ func (b *BlockChain) checkTransactionsAndConnect(inputFees dcrutil.Amount, " pays %v which is more than expected value "+ "of %v", node.hash, totalAtomOutRegular, expAtomOut) - return ruleError(ErrBadCoinbaseValue, str) + return 0, ruleError(ErrBadCoinbaseValue, str) } } else { // TxTreeStake // When treasury is enabled check treasurybase value @@ -3958,7 +3890,7 @@ func (b *BlockChain) checkTransactionsAndConnect(inputFees dcrutil.Amount, str := fmt.Sprintf("empty tx tree stake, "+ "expected treasurybase at height %v", node.height) - return ruleError(ErrNoStakeTx, str) + return 0, ruleError(ErrNoStakeTx, str) } subsidyTax := b.subsidyCache.CalcTreasurySubsidy(node.height, node.voters, isTreasuryEnabled) @@ -3968,30 +3900,30 @@ func (b *BlockChain) checkTransactionsAndConnect(inputFees dcrutil.Amount, "subsidy in input; got %v, expected %v", treasurybaseIn.ValueIn, subsidyTax) - return ruleError(ErrBadTreasurybaseAmountIn, errStr) + return 0, ruleError(ErrBadTreasurybaseAmountIn, errStr) } } if len(txs) == 0 && node.height < b.chainParams.StakeValidationHeight { - return nil + return dcrutil.Amount(totalFees), nil } if len(txs) == 0 && node.height >= b.chainParams.StakeValidationHeight { str := fmt.Sprintf("empty tx tree stake in block " + "after stake validation height") - return ruleError(ErrNoStakeTx, str) + return 0, ruleError(ErrNoStakeTx, str) } err := checkStakeBaseAmounts(b.subsidyCache, node.height, txs, view, subsidySplitVariant) if err != nil { - return err + return 0, err } totalAtomOutStake, err := getStakeBaseAmounts(txs, view) if err != nil { - return err + return 0, err } var expAtomOut int64 @@ -4007,11 +3939,11 @@ func (b *BlockChain) checkTransactionsAndConnect(inputFees dcrutil.Amount, str := fmt.Sprintf("stakebase transactions for block pays %v "+ "which is more than expected value of %v", totalAtomOutStake, expAtomOut) - return ruleError(ErrBadStakebaseValue, str) + return 0, ruleError(ErrBadStakebaseValue, str) } } - return nil + return dcrutil.Amount(totalFees), nil } // consensusScriptVerifyFlags returns the script flags that must be used when @@ -4247,8 +4179,8 @@ func (b *BlockChain) checkConnectBlock(node *blockNode, block, parent *dcrutil.B } const stakeTreeTrue = true - err = b.checkTransactionsAndConnect(0, node, block.STransactions(), - view, stxos, stakeTreeTrue, subsidySplitVariant) + stakeTreeFees, err := b.checkTransactionsAndConnect(0, node, + block.STransactions(), view, stxos, stakeTreeTrue, subsidySplitVariant) if err != nil { log.Tracef("checkTransactionsAndConnect failed for stake tree: %v", err) return err @@ -4264,13 +4196,6 @@ func (b *BlockChain) checkConnectBlock(node *blockNode, block, parent *dcrutil.B } } - stakeTreeFees, err := getStakeTreeFees(b.subsidyCache, node.height, - block.STransactions(), view, isTreasuryEnabled, subsidySplitVariant) - if err != nil { - log.Tracef("getStakeTreeFees failed for stake tree: %v", err) - return err - } - // Enforce all relative lock times via sequence numbers for the stake // transaction tree once the stake vote for the agenda is active. var prevMedianTime time.Time @@ -4320,7 +4245,7 @@ func (b *BlockChain) checkConnectBlock(node *blockNode, block, parent *dcrutil.B } const stakeTreeFalse = false - err = b.checkTransactionsAndConnect(stakeTreeFees, node, + _, err = b.checkTransactionsAndConnect(stakeTreeFees, node, block.Transactions(), view, stxos, stakeTreeFalse, subsidySplitVariant) if err != nil { log.Tracef("checkTransactionsAndConnect failed for regular tree: %v", From 0598f523bbcc1ed316f379cf6a41777497dc06f4 Mon Sep 17 00:00:00 2001 From: Dave Collins Date: Wed, 6 May 2026 07:07:37 -0500 Subject: [PATCH 19/19] blockchain: Cleanup tx input and fee overflow. This updates the transaction input and fee summing code to make use of the new consolidated add funcs with cleaner overflow detection. It also consolidates the input summing for all transaction types into a new closure instead of repeating the logic multiple times throughout the input checks function. Consolidating it makes it more readable and less error prone. Finally, while here, it consolidates, cleans up and slightly optimizes the input sum handling for the transaction types that do not have normal inputs. Namely, first the stakebase summing is changes to use the input value field instead of recalculating the subsidy to make it consistent with the treasurybase and treasury spend cases. This is safe because the code earlier in the function ensures the values matches the expected amounts. Second, it combines the checks for all three types since that change makes the checks for them identical. --- internal/blockchain/validate.go | 105 ++++++++++++++++++++++---------- 1 file changed, 73 insertions(+), 32 deletions(-) diff --git a/internal/blockchain/validate.go b/internal/blockchain/validate.go index e16b679c43..f4f89a30e3 100644 --- a/internal/blockchain/validate.go +++ b/internal/blockchain/validate.go @@ -3165,7 +3165,7 @@ func checkTreasurySpendInputs(msgTx *wire.MsgTx) error { // that value. // // NOTE: The transaction MUST have already been sanity checked with the -// standalone.CheckTransactionSanity function prior to calling this function. +// [standalone.CheckTransactionSanity] function prior to calling this function. func CheckTransactionInputs(subsidyCache *standalone.SubsidyCache, tx *dcrutil.Tx, txHeight int64, view *UtxoViewpoint, checkFraudProof bool, chainParams *chaincfg.Params, prevHeader *wire.BlockHeader, @@ -3276,22 +3276,60 @@ func CheckTransactionInputs(subsidyCache *standalone.SubsidyCache, // Decred general transaction testing (and a few stake exceptions). // ------------------------------------------------------------------- - txHash := tx.Hash() + // sumTotalAtomIn is a convenience func that adds the provided amount to the + // total sum of all inputs while ensuring that the accumulator does not + // overflow. It also ensures the total does not exceed the max allowed per + // transaction. + // + // In practice, at the time this comment is being written, it is not + // possible to overflow the accumulator due to the combination of limits + // placed on the amounts and number of inputs possible per transaction, but + // be safe and check anyway in case that is no longer true at some point in + // the future. var totalAtomIn int64 - for idx, txIn := range msgTx.TxIn { - // Inputs won't exist for stakebase tx, so ignore them. - if isVote && idx == 0 { - // However, do add the reward amount. - _, heightVotingOn := stake.SSGenBlockVotedOn(msgTx) - stakeVoteSubsidy := subsidyCache.CalcStakeVoteSubsidyV3( - int64(heightVotingOn), subsidySplitVariant) - totalAtomIn += stakeVoteSubsidy - continue + sumTotalAtomIn := func(amount int64) error { + var ok bool + totalAtomIn, ok = addSigned(totalAtomIn, amount) + if !ok { + const str = "total value of all transaction inputs overflows " + + "accumulator" + return ruleError(ErrBadTxOutValue, str) + } + if totalAtomIn > dcrutil.MaxAmount { + str := fmt.Sprintf("total value of all transaction inputs is %v "+ + "which is higher than max allowed value of %v", totalAtomIn, + dcrutil.MaxAmount) + return ruleError(ErrBadTxOutValue, str) } - // idx can only be 0 in this case but check it anyway. - if (isTreasuryBase || isTreasurySpend) && idx == 0 { - totalAtomIn += txIn.ValueIn + return nil + } + + txHash := tx.Hash() + for idx, txIn := range msgTx.TxIn { + // The stakebase of votes, treasurybases, and treasuryspends do not have + // normal inputs, so handle them separately. + // + // Their input value commitments all contribute to the total input sum + // and are safe to use here because they have been proven to commit to + // correct values that are in a valid range previously. + // + // The input values correspond to the following: + // - Stakebases: the stake vote subsidy + // - Treasurybases: the treasury subsidy + // - Treasury spends: the amount to deduct from the treasury + // + // Treasurybases and treasury spends only have a single input, so their + // index can only be 0. That means their input sums could technically + // just be set directly. However, be paranoid and double check in case + // that ever changes in the future. + if idx == 0 && (isVote || isTreasuryBase || isTreasurySpend) { + // The total of all input amounts must not be more than the max + // allowed per transaction. Also, ensure the accumulator does not + // overflow. + if err := sumTotalAtomIn(txIn.ValueIn); err != nil { + return 0, err + } continue } @@ -3452,16 +3490,10 @@ func CheckTransactionInputs(subsidyCache *standalone.SubsidyCache, return 0, ruleError(ErrBadTxOutValue, str) } - // The total of all outputs must not be more than the max allowed per - // transaction. Also, we could potentially overflow the accumulator so - // check for overflow. - lastAtomIn := totalAtomIn - totalAtomIn += originTxAtom - if totalAtomIn < lastAtomIn || totalAtomIn > dcrutil.MaxAmount { - str := fmt.Sprintf("total value of all transaction inputs is %v "+ - "which is higher than max allowed value of %v", totalAtomIn, - dcrutil.MaxAmount) - return 0, ruleError(ErrBadTxOutValue, str) + // The total of all input amounts must not be more than the max allowed + // per transaction. Also, ensure the accumulator does not overflow. + if err := sumTotalAtomIn(originTxAtom); err != nil { + return 0, err } } @@ -3794,13 +3826,11 @@ func (b *BlockChain) checkTransactionsAndConnect(inputFees dcrutil.Amount, return 0, err } - // Sum the total fees and ensure we don't overflow the - // accumulator. - lastTotalFees := totalFees - totalFees += txFee - if totalFees < lastTotalFees { - return 0, ruleError(ErrBadFees, "total fees for block "+ - "overflows accumulator") + // Sum the total fees and ensure they do overflow the accumulator. + totalFees, ok = addSigned(totalFees, txFee) + if !ok { + const str = "total fees for block overflows accumulator" + return 0, ruleError(ErrBadFees, str) } // Update the view to mark all utxos spent by the transaction as spent @@ -4017,8 +4047,19 @@ func (b *BlockChain) tspendChecks(prevNode *blockNode, block *dcrutil.Block) err // A valid treasury spend always stores the entire amount that the // treasury is spending in the first input. It is safe to use since it // has already been verified to match the commitment value. + // + // The extra overflow checks could technically be avoided here because + // the treasury spends are a subset of all transactions in the tree + // which means the overall sum for all transactions would overflow and + // cause the block to be rejected anyway, but be paranoid to protect + // against refactors violating that assumption. + var ok bool valueIn := stx.MsgTx().TxIn[0].ValueIn - totalTSpendAmount += valueIn + totalTSpendAmount, ok = addSigned(totalTSpendAmount, valueIn) + if !ok { + const str = "total value of all treasury spends overflows accumulator" + return ruleError(ErrBadTxOutValue, str) + } // Verify this TSpend hash has not been included in a // prior block.