From f41c91dbe2f52340cea07fd856ec91687ead8a22 Mon Sep 17 00:00:00 2001 From: Chris Gianelloni Date: Mon, 13 Jul 2026 09:57:33 -0400 Subject: [PATCH 1/2] consensus: align sequence locks and unknown covenants Signed-off-by: Chris Gianelloni --- blockchain/chain.go | 10 ++--- blockchain/chain_test.go | 8 ++-- blockchain/names.go | 25 +++++++++++- blockchain/names_test.go | 86 ++++++++++++++++++++++++++++++++++++++++ txscript/engine_test.go | 36 +++++++++++++++++ txscript/opcode.go | 8 ---- wire/covenant.go | 36 ++++++++--------- wire/covenant_test.go | 18 +++++++++ 8 files changed, 190 insertions(+), 37 deletions(-) diff --git a/blockchain/chain.go b/blockchain/chain.go index 97f2f9d3..95e52642 100644 --- a/blockchain/chain.go +++ b/blockchain/chain.go @@ -383,12 +383,12 @@ func (b *BlockChain) calcSequenceLock(node *blockNode, tx *hnsutil.Tx, utxoView // any given height or time. sequenceLock := &SequenceLock{Seconds: -1, BlockHeight: -1} - // Handshake sequence-lock semantics are active consensus rules for - // version 2+ transactions. They are not gated behind Bitcoin's BIP9 CSV - // deployment. Sequence locks also do not apply to coinbase transactions. + // Handshake sequence-lock semantics are active consensus rules for all + // transaction versions. They are not gated behind Bitcoin's BIP9 CSV + // deployment or the inherited Bitcoin tx version >= 2 rule. Sequence locks + // also do not apply to coinbase transactions. mTx := tx.MsgTx() - sequenceLockActive := uint32(mTx.Version) >= 2 - if !sequenceLockActive || IsCoinBase(tx) { + if IsCoinBase(tx) { return sequenceLock, nil } diff --git a/blockchain/chain_test.go b/blockchain/chain_test.go index 3b48d9ad..eb9bb203 100644 --- a/blockchain/chain_test.go +++ b/blockchain/chain_test.go @@ -223,12 +223,10 @@ func TestCalcSequenceLock(t *testing.T) { mempool bool want *SequenceLock }{ - // A transaction of version one should disable sequence locks - // as the new sequence number semantics only apply to - // transactions version 2 or higher. + // Handshake applies sequence locks to version 0 transactions. { tx: &wire.MsgTx{ - Version: 1, + Version: 0, TxIn: []*wire.TxIn{{ PreviousOutPoint: utxo, Sequence: LockTimeToSequence(false, 3), @@ -237,7 +235,7 @@ func TestCalcSequenceLock(t *testing.T) { view: utxoView, want: &SequenceLock{ Seconds: -1, - BlockHeight: -1, + BlockHeight: prevUtxoHeight + 2, }, }, // A transaction with a single input with max sequence number. diff --git a/blockchain/names.go b/blockchain/names.go index b8c1ad6d..9848b2e2 100644 --- a/blockchain/names.go +++ b/blockchain/names.go @@ -21,6 +21,9 @@ const ( maxNameSize = 63 maxResourceSize = 512 + maxCovenantItems = 1000 + maxCovenantSize = 1 + 32 + 1 + 4 + 2 + maxResourceSize + 1 + 32 + maxBlockNameOpens = 300 maxBlockNameUpdates = 600 maxBlockNameRenewals = 600 @@ -868,13 +871,33 @@ func checkCovenantSanity(tx *hnsutil.Tx) error { return err } default: - return badCovenant("unknown covenant type") + if err := checkUnknownCovenant(covenant); err != nil { + return err + } } } return nil } +func checkUnknownCovenant(covenant wire.Covenant) error { + if len(covenant.Items) > maxCovenantItems { + return badCovenant("unknown covenant has too many items") + } + if covenantItemsSize(covenant) > maxCovenantSize { + return badCovenant("unknown covenant is too large") + } + return nil +} + +func covenantItemsSize(covenant wire.Covenant) int { + size := 0 + for _, item := range covenant.Items { + size += wire.VarIntSerializeSize(uint64(len(item))) + len(item) + } + return size +} + func checkTransactionNameLimits(tx *hnsutil.Tx) error { var opens, updates, renewals int diff --git a/blockchain/names_test.go b/blockchain/names_test.go index d362830e..66b4aee7 100644 --- a/blockchain/names_test.go +++ b/blockchain/names_test.go @@ -108,6 +108,67 @@ func TestCheckCovenantSanityOpenRejectsHashMismatch(t *testing.T) { } } +func TestCheckCovenantSanityUnknownCovenants(t *testing.T) { + t.Run("allows hsd max item size", func(t *testing.T) { + tx := wire.NewMsgTx(1) + tx.AddTxIn(wire.NewTxIn(testOutPoint(1), math.MaxUint32, + nil)) + tx.AddTxOut(wire.NewTxOut(1, wire.Address{}, wire.Covenant{ + Type: wire.CovenantRevoke + 1, + Items: [][]byte{ + make([]byte, maxCovenantSize- + wire.VarIntSerializeSize(uint64(maxCovenantSize))), + }, + })) + + if err := CheckTransactionSanity(hnsutil.NewTx(tx)); err != nil { + t.Fatalf("CheckTransactionSanity: %v", err) + } + }) + + t.Run("allows hsd max empty item count by size", func(t *testing.T) { + items := make([][]byte, maxCovenantSize) + for i := range items { + items[i] = []byte{} + } + + tx := wire.NewMsgTx(1) + tx.AddTxIn(wire.NewTxIn(testOutPoint(2), math.MaxUint32, + nil)) + tx.AddTxOut(wire.NewTxOut(1, wire.Address{}, wire.Covenant{ + Type: wire.CovenantRevoke + 1, + Items: items, + })) + + if err := CheckTransactionSanity(hnsutil.NewTx(tx)); err != nil { + t.Fatalf("CheckTransactionSanity: %v", err) + } + }) + + t.Run("rejects over hsd covenant size", func(t *testing.T) { + tx := wire.NewMsgTx(1) + tx.AddTxIn(wire.NewTxIn(testOutPoint(3), math.MaxUint32, + nil)) + tx.AddTxOut(wire.NewTxOut(1, wire.Address{}, wire.Covenant{ + Type: wire.CovenantRevoke + 1, + Items: [][]byte{ + make([]byte, maxCovenantSize), + }, + })) + + err := CheckTransactionSanity(hnsutil.NewTx(tx)) + if err == nil { + t.Fatal("CheckTransactionSanity: expected size error") + } + if ruleErr, ok := err.(RuleError); !ok || + ruleErr.ErrorCode != ErrInvalidCovenant { + + t.Fatalf("CheckTransactionSanity error = %T %v, want ErrInvalidCovenant", + err, err) + } + }) +} + func TestCheckBlockNameLimitsDuplicateAcrossTransactions(t *testing.T) { block := &wire.MsgBlock{} for i := 0; i < 2; i++ { @@ -276,6 +337,31 @@ func TestVerifyCovenantSpendBidRevealBlind(t *testing.T) { } } +func TestVerifyCovenantSpendUnknownCovenant(t *testing.T) { + prev := namePrevOutput{ + outpoint: *testOutPoint(1), + amount: 10, + covenant: wire.Covenant{ + Type: wire.CovenantRevoke + 1, + Items: [][]byte{ + []byte("future"), + }, + }, + } + + if err := verifyCovenantSpend(prev, + wire.NewTxOut(1, wire.Address{}, wire.Covenant{})); err != nil { + + t.Fatalf("verifyCovenantSpend unknown to NONE: %v", err) + } + + if err := verifyCovenantSpend(prev, + wire.NewTxOut(1, wire.Address{}, openCovenant("known"))); err == nil { + + t.Fatal("verifyCovenantSpend: expected unknown to known name rejection") + } +} + func TestNameBlockViewOpenLifecycle(t *testing.T) { params := chaincfg.RegressionNetParams params.NameNoRollout = true diff --git a/txscript/engine_test.go b/txscript/engine_test.go index 46091f9e..3df69fd2 100644 --- a/txscript/engine_test.go +++ b/txscript/engine_test.go @@ -245,6 +245,42 @@ func TestStandardVerifyFlagsExcludeTaproot(t *testing.T) { } } +func TestCheckSequenceVerifyAllowsVersionZero(t *testing.T) { + t.Parallel() + + pkScript, err := NewScriptBuilder(). + AddInt64(1). + AddOp(OP_CHECKSEQUENCEVERIFY). + AddOp(OP_TRUE). + Script() + if err != nil { + t.Fatalf("script build: %v", err) + } + + tx := &wire.MsgTx{ + Version: 0, + TxIn: []*wire.TxIn{{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{0x01}, + Index: 0, + }, + Sequence: 1, + }}, + TxOut: []*wire.TxOut{{ + Value: 1000000000, + }}, + } + + vm, err := NewEngine(pkScript, tx, 0, ScriptVerifyCheckSequenceVerify, + nil, nil, -1, nil) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + if err := vm.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } +} + // TestCheckPubKeyEncoding ensures the internal checkPubKeyEncoding function // works as expected. func TestCheckPubKeyEncoding(t *testing.T) { diff --git a/txscript/opcode.go b/txscript/opcode.go index 9f9e17b9..30789199 100644 --- a/txscript/opcode.go +++ b/txscript/opcode.go @@ -1181,14 +1181,6 @@ func opcodeCheckSequenceVerify(op *opcode, data []byte, vm *Engine) error { return nil } - // Transaction version numbers not high enough to trigger CSV rules must - // fail. - if uint32(vm.tx.Version) < 2 { - str := fmt.Sprintf("invalid transaction version: %d", - vm.tx.Version) - return scriptError(ErrUnsatisfiedLockTime, str) - } - // Sequence numbers with their most significant bit set are not // consensus constrained. Testing that the transaction's sequence // number does not have this bit set prevents using this property diff --git a/wire/covenant.go b/wire/covenant.go index d6268205..6b51d705 100644 --- a/wire/covenant.go +++ b/wire/covenant.go @@ -46,16 +46,17 @@ const ( // CovenantRevoke represents revoking a name. CovenantRevoke uint8 = 11 - // maxCovenantType is the highest valid covenant type value. + // maxCovenantType is the highest known covenant type value. maxCovenantType = CovenantRevoke // maxCovenantItems is the maximum number of items a covenant can have. - // This is based on the Handshake protocol's maximum of 255 items. - maxCovenantItems = 255 + // This matches hsd's consensus MAX_SCRIPT_STACK bound. + maxCovenantItems = 1000 // maxCovenantItemSize is the maximum size of a single covenant item. - // This limits memory usage during deserialization. - maxCovenantItemSize = 520 + // It is large enough to admit every hsd-sane unknown covenant while still + // bounding memory during deserialization. + maxCovenantItemSize = 585 ) // covenantTypeNames maps covenant types to their human-readable names. @@ -89,13 +90,6 @@ type Covenant struct { func validateCovenantFields(covenantType uint8, itemCount int, items [][]byte, op string) error { - if covenantType > maxCovenantType { - return messageError(op, fmt.Sprintf( - "covenant type %d exceeds max %d", - covenantType, maxCovenantType, - )) - } - if itemCount > maxCovenantItems { str := fmt.Sprintf("covenant item count is too large "+ "[count %d, max %d]", itemCount, maxCovenantItems) @@ -151,12 +145,6 @@ func (c *Covenant) Decode(r io.Reader) error { return err } c.Type = typ - if typ > maxCovenantType { - return messageError("Covenant.Decode", fmt.Sprintf( - "covenant type %d exceeds max %d", - typ, maxCovenantType, - )) - } itemCount, err := ReadVarInt(r, 0) if err != nil { @@ -208,6 +196,18 @@ func (c *Covenant) String() string { return fmt.Sprintf("UNKNOWN(%d)", c.Type) } +// IsKnown returns whether the covenant type is one of the currently defined +// Handshake covenant types. +func (c *Covenant) IsKnown() bool { + return c.Type <= maxCovenantType +} + +// IsUnknown returns whether the covenant type is reserved for a future +// Handshake covenant extension. +func (c *Covenant) IsUnknown() bool { + return !c.IsKnown() +} + // NewCovenant returns a new Covenant with the given type and items. func NewCovenant(covenantType uint8, items [][]byte) *Covenant { return &Covenant{ diff --git a/wire/covenant_test.go b/wire/covenant_test.go index 16562329..0b8b38f7 100644 --- a/wire/covenant_test.go +++ b/wire/covenant_test.go @@ -43,6 +43,12 @@ func TestCovenantEncodeDecode(t *testing.T) { name: "NONE covenant with empty items slice", covenant: NewCovenant(CovenantNone, [][]byte{}), }, + { + name: "unknown covenant with hsd-sized item", + covenant: NewCovenant(CovenantRevoke+1, [][]byte{ + make([]byte, 582), + }), + }, { name: "REGISTER covenant with empty item", covenant: NewCovenant(CovenantRegister, [][]byte{ @@ -240,6 +246,18 @@ func TestCovenantString(t *testing.T) { } } +func TestCovenantKnownUnknown(t *testing.T) { + known := NewCovenant(CovenantRevoke, nil) + if !known.IsKnown() || known.IsUnknown() { + t.Fatal("REVOKE covenant should be known") + } + + unknown := NewCovenant(CovenantRevoke+1, nil) + if unknown.IsKnown() || !unknown.IsUnknown() { + t.Fatal("covenant above REVOKE should be unknown") + } +} + // TestAddressEncodeDecode tests round-trip encoding and decoding of addresses // with both 20-byte and 32-byte hashes at version 0. func TestAddressEncodeDecode(t *testing.T) { From f3f7ec668b8c0b099a4d6c8b2fb934a9d54823f4 Mon Sep 17 00:00:00 2001 From: Chris Gianelloni Date: Tue, 14 Jul 2026 09:24:14 -0400 Subject: [PATCH 2/2] Fix unknown covenant serialized size checks Signed-off-by: Chris Gianelloni --- blockchain/names.go | 16 ++++------------ blockchain/names_test.go | 19 +++++++++++++++---- wire/covenant.go | 6 ++++-- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/blockchain/names.go b/blockchain/names.go index 9848b2e2..2a1bb287 100644 --- a/blockchain/names.go +++ b/blockchain/names.go @@ -21,8 +21,8 @@ const ( maxNameSize = 63 maxResourceSize = 512 - maxCovenantItems = 1000 - maxCovenantSize = 1 + 32 + 1 + 4 + 2 + maxResourceSize + 1 + 32 + // This matches hsd's MAX_COVENANT_SIZE formula for unknown covenants. + maxCovenantSize = 1 + 32 + 1 + 4 + 2 + maxResourceSize + 1 + 32 maxBlockNameOpens = 300 maxBlockNameUpdates = 600 @@ -881,23 +881,15 @@ func checkCovenantSanity(tx *hnsutil.Tx) error { } func checkUnknownCovenant(covenant wire.Covenant) error { - if len(covenant.Items) > maxCovenantItems { + if len(covenant.Items) > wire.MaxCovenantItems { return badCovenant("unknown covenant has too many items") } - if covenantItemsSize(covenant) > maxCovenantSize { + if covenant.SerializeSize() > maxCovenantSize { return badCovenant("unknown covenant is too large") } return nil } -func covenantItemsSize(covenant wire.Covenant) int { - size := 0 - for _, item := range covenant.Items { - size += wire.VarIntSerializeSize(uint64(len(item))) + len(item) - } - return size -} - func checkTransactionNameLimits(tx *hnsutil.Tx) error { var opens, updates, renewals int diff --git a/blockchain/names_test.go b/blockchain/names_test.go index 66b4aee7..eaeb15c4 100644 --- a/blockchain/names_test.go +++ b/blockchain/names_test.go @@ -110,24 +110,31 @@ func TestCheckCovenantSanityOpenRejectsHashMismatch(t *testing.T) { func TestCheckCovenantSanityUnknownCovenants(t *testing.T) { t.Run("allows hsd max item size", func(t *testing.T) { + const maxSingleItemSize = 580 + tx := wire.NewMsgTx(1) tx.AddTxIn(wire.NewTxIn(testOutPoint(1), math.MaxUint32, nil)) tx.AddTxOut(wire.NewTxOut(1, wire.Address{}, wire.Covenant{ Type: wire.CovenantRevoke + 1, Items: [][]byte{ - make([]byte, maxCovenantSize- - wire.VarIntSerializeSize(uint64(maxCovenantSize))), + make([]byte, maxSingleItemSize), }, })) + if got := tx.TxOut[0].Covenant.SerializeSize(); got != maxCovenantSize { + t.Fatalf("covenant size = %d, want %d", got, + maxCovenantSize) + } if err := CheckTransactionSanity(hnsutil.NewTx(tx)); err != nil { t.Fatalf("CheckTransactionSanity: %v", err) } }) t.Run("allows hsd max empty item count by size", func(t *testing.T) { - items := make([][]byte, maxCovenantSize) + const maxEmptyItemsBySize = 581 + + items := make([][]byte, maxEmptyItemsBySize) for i := range items { items[i] = []byte{} } @@ -140,6 +147,10 @@ func TestCheckCovenantSanityUnknownCovenants(t *testing.T) { Items: items, })) + if got := tx.TxOut[0].Covenant.SerializeSize(); got != maxCovenantSize { + t.Fatalf("covenant size = %d, want %d", got, + maxCovenantSize) + } if err := CheckTransactionSanity(hnsutil.NewTx(tx)); err != nil { t.Fatalf("CheckTransactionSanity: %v", err) } @@ -152,7 +163,7 @@ func TestCheckCovenantSanityUnknownCovenants(t *testing.T) { tx.AddTxOut(wire.NewTxOut(1, wire.Address{}, wire.Covenant{ Type: wire.CovenantRevoke + 1, Items: [][]byte{ - make([]byte, maxCovenantSize), + make([]byte, 581), }, })) diff --git a/wire/covenant.go b/wire/covenant.go index 6b51d705..071c3209 100644 --- a/wire/covenant.go +++ b/wire/covenant.go @@ -49,9 +49,11 @@ const ( // maxCovenantType is the highest known covenant type value. maxCovenantType = CovenantRevoke - // maxCovenantItems is the maximum number of items a covenant can have. + // MaxCovenantItems is the maximum number of items a covenant can have. // This matches hsd's consensus MAX_SCRIPT_STACK bound. - maxCovenantItems = 1000 + MaxCovenantItems = 1000 + + maxCovenantItems = MaxCovenantItems // maxCovenantItemSize is the maximum size of a single covenant item. // It is large enough to admit every hsd-sane unknown covenant while still