txscript: reject OP_CODESEPARATOR in unexecuted branches for non-segwit
What changed, and why it matters
This commit fixes a minor policy-level mismatch between btcd and Bitcoin Core. A special script command called OP_CODESEPARATOR, when placed inside an 'IF' branch that never runs, was being rejected by Bitcoin Core's mempool but accepted by btcd. The change makes btcd reject it too, but only as a mempool policy rule—not a consensus rule—so already-mined transactions would still be valid to both. It was found through automated fuzz testing.
No urgent action required. This is a policy-hardening fix with no consensus impact. Operators running btcd as a mempool/policy node should upgrade to maintain consistency with Bitcoin Core's standardness rules. Consensus validation is unaffected.
Security signals we found
Policy-level divergence from reference implementation (Bitcoin Core)
OP_CODESEPARATOR handling in unexecuted branches
ScriptVerifyConstScriptCode flag enforcement
Differential fuzzing discovery
Evidence from the diff
The patch moves the ScriptVerifyConstScriptCode rejection of OP_CODESEPARATOR for non-segwit scripts from the opcodeCodeSeparator handler into executeOpcode, placing it before the branch-execution gate. This aligns btcd with Bitcoin Core’s interpreter.cpp behavior, where the check fires unconditionally during script iteration even inside unexecuted OP_IF branches. The change is policy-only: SCRIPT_VERIFY_CONST_SCRIPTCODE is part of STANDARD_SCRIPT_VERIFY_FLAGS, not MANDATORY_SCRIPT_VERIFY_FLAGS, so no consensus divergence existed. Tests cover unexecuted, executed, nested, and segwit cases.
Changed components
txscript/engine.gotxscript/opcode.gotxscript/engine_test.goInspect captured patch +160 / −7
diff --git a/txscript/engine.go b/txscript/engine.go
index 0cc3d96..b443e40 100644
--- a/txscript/engine.go
+++ b/txscript/engine.go
@@ -485,6 +485,26 @@ func (vm *Engine) executeOpcode(op *opcode, data []byte) error {
return scriptError(ErrElementTooBig, str)
}
+ // With ScriptVerifyConstScriptCode, OP_CODESEPARATOR in a non-segwit
+ // script is rejected even in an unexecuted branch. The script is
+ // non-segwit when neither a witness program nor a taproot execution
+ // context has been recorded on the engine: vm.witnessProgram is set
+ // for v0/v1 native witness spends and nested P2SH-witness spends,
+ // and vm.taprootCtx is set once the engine has recursed into the
+ // taproot script-path layer. Both nil means we are still executing
+ // a legacy script (scriptSig + scriptPubKey, or a P2SH redeem
+ // script), which is the only case the const-scriptcode rule
+ // applies to. The check is performed here, before the branch
+ // execution gate below, so it fires unconditionally on every
+ // OP_CODESEPARATOR encountered during script iteration.
+ if op.value == OP_CODESEPARATOR && vm.taprootCtx == nil &&
+ vm.witnessProgram == nil &&
+ vm.hasFlag(ScriptVerifyConstScriptCode) {
+
+ str := "OP_CODESEPARATOR used in non-segwit script"
+ return scriptError(ErrCodeSeparator, str)
+ }
+
// Nothing left to do when this is not a conditional opcode and it is
// not in an executing branch.
if !vm.isBranchExecuting() && !isOpcodeConditional(op.value) {
diff --git a/txscript/engine_test.go b/txscript/engine_test.go
index c88d27a..9f2f818 100644
--- a/txscript/engine_test.go
+++ b/txscript/engine_test.go
@@ -6,6 +6,7 @@
package txscript
import (
+ "crypto/sha256"
"testing"
"github.com/btcsuite/btcd/chaincfg/chainhash"
@@ -426,3 +427,138 @@ func TestCheckSignatureEncoding(t *testing.T) {
}
}
}
+
+// TestCodeSepUnexecutedBranch ensures that OP_CODESEPARATOR is rejected in
+// non-segwit scripts even when inside an unexecuted OP_IF branch, when the
+// ScriptVerifyConstScriptCode flag is set. This matches Bitcoin Core's
+// behavior where the SCRIPT_VERIFY_CONST_SCRIPTCODE check fires
+// unconditionally before the branch execution gate.
+func TestCodeSepUnexecutedBranch(t *testing.T) {
+ t.Parallel()
+
+ // A minimal transaction for script execution.
+ tx := &wire.MsgTx{
+ Version: 1,
+ TxIn: []*wire.TxIn{{
+ PreviousOutPoint: wire.OutPoint{
+ Hash: chainhash.Hash([32]byte{
+ 0xc9, 0x97, 0xa5, 0xe5,
+ 0x6e, 0x10, 0x41, 0x02,
+ 0xfa, 0x20, 0x9c, 0x6a,
+ 0x85, 0x2d, 0xd9, 0x06,
+ 0x60, 0xa2, 0x0b, 0x2d,
+ 0x9c, 0x35, 0x24, 0x23,
+ 0xed, 0xce, 0x25, 0x85,
+ 0x7f, 0xcd, 0x37, 0x04,
+ }),
+ Index: 0,
+ },
+ SignatureScript: mustParseShortForm("TRUE"),
+ Sequence: 4294967295,
+ }},
+ TxOut: []*wire.TxOut{{
+ Value: 1000000000,
+ PkScript: nil,
+ }},
+ LockTime: 0,
+ }
+
+ tests := []struct {
+ name string
+ script string
+ flags ScriptFlags
+ segwit bool
+ wantErr bool
+ errCode ErrorCode
+ }{
+ {
+ // OP_CODESEPARATOR inside an unexecuted branch with
+ // the const scriptcode flag set should be rejected.
+ name: "codesep in unexecuted IF with const scriptcode",
+ script: "0 IF CODESEPARATOR ENDIF TRUE",
+ flags: ScriptVerifyConstScriptCode,
+ wantErr: true,
+ errCode: ErrCodeSeparator,
+ },
+ {
+ // Without the flag, OP_CODESEPARATOR in an unexecuted
+ // branch should be fine (consensus behavior).
+ name: "codesep in unexecuted IF without const scriptcode",
+ script: "0 IF CODESEPARATOR ENDIF TRUE",
+ flags: 0,
+ wantErr: false,
+ },
+ {
+ // OP_CODESEPARATOR in an executed branch with the
+ // const scriptcode flag should also be rejected.
+ name: "codesep in executed branch with const scriptcode",
+ script: "CODESEPARATOR TRUE",
+ flags: ScriptVerifyConstScriptCode,
+ wantErr: true,
+ errCode: ErrCodeSeparator,
+ },
+ {
+ // Nested unexecuted branches should still be caught.
+ name: "codesep in nested unexecuted IF with const scriptcode",
+ script: "0 IF 1 IF CODESEPARATOR ENDIF ENDIF TRUE",
+ flags: ScriptVerifyConstScriptCode,
+ wantErr: true,
+ errCode: ErrCodeSeparator,
+ },
+ {
+ // OP_CODESEPARATOR in a segwit P2WSH witness script
+ // should succeed even with const scriptcode, since
+ // the flag only applies to non-segwit scripts.
+ name: "codesep in segwit P2WSH with const scriptcode",
+ script: "CODESEPARATOR TRUE",
+ flags: ScriptVerifyConstScriptCode | ScriptVerifyWitness | ScriptBip16,
+ segwit: true,
+ wantErr: false,
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ scriptBytes := mustParseShortForm(test.script)
+
+ pkScript := scriptBytes
+ testTx := tx
+ if test.segwit {
+ hash := sha256.Sum256(scriptBytes)
+ pkScript, _ = NewScriptBuilder().
+ AddOp(OP_0).AddData(hash[:]).Script()
+ testTx = tx.Copy()
+ testTx.TxIn[0].SignatureScript = nil
+ testTx.TxIn[0].Witness = wire.TxWitness{
+ scriptBytes,
+ }
+ }
+
+ vm, err := NewEngine(
+ pkScript, testTx, 0, test.flags, nil, nil,
+ -1, nil,
+ )
+ if err != nil {
+ t.Fatalf("failed to create engine: %v", err)
+ }
+
+ err = vm.Execute()
+
+ switch {
+ case test.wantErr && err == nil:
+ t.Fatal("expected error but execution " +
+ "succeeded")
+
+ case !test.wantErr && err != nil:
+ t.Fatalf("unexpected error: %v", err)
+
+ case test.wantErr && err != nil:
+ if !IsErrorCode(err, test.errCode) {
+ t.Fatalf("expected error code "+
+ "%v, got: %v",
+ test.errCode, err)
+ }
+ }
+ })
+ }
+}
diff --git a/txscript/opcode.go b/txscript/opcode.go
index 770e5b4..7326dab 100644
--- a/txscript/opcode.go
+++ b/txscript/opcode.go
@@ -1947,18 +1947,15 @@ func opcodeHash256(op *opcode, data []byte, vm *Engine) error {
// opcodeCodeSeparator stores the current script offset as the most recently
// seen OP_CODESEPARATOR which is used during signature checking.
//
-// This opcode does not change the contents of the data stack.
+// This opcode does not change the contents of the data stack. The
+// non-segwit ScriptVerifyConstScriptCode rejection is enforced in
+// executeOpcode before this handler is dispatched, so it is not
+// re-checked here.
func opcodeCodeSeparator(op *opcode, data []byte, vm *Engine) error {
vm.lastCodeSep = int(vm.tokenizer.ByteIndex())
if vm.taprootCtx != nil {
vm.taprootCtx.codeSepPos = uint32(vm.tokenizer.OpcodePosition())
- } else if vm.witnessProgram == nil &&
- vm.hasFlag(ScriptVerifyConstScriptCode) {
-
- // Disable OP_CODESEPARATOR for non-segwit scripts.
- str := "OP_CODESEPARATOR used in non-segwit script"
- return scriptError(ErrCodeSeparator, str)
}
return nil
Why this scored 44/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.