txscript: recognize P2A witness program during script execution
What changed, and why it matters
This commit changes how btcd recognizes a new Bitcoin output type called 'pay-to-anchor' (P2A) when checking whether a transaction is allowed to spend it. Before the change, P2A outputs were treated as an unknown future witness version, which could cause btcd to reject valid P2A spends or behave differently from Bitcoin Core. The fix makes btcd explicitly accept P2A spends as valid 'anyone-can-spend' outputs, matching expected network rules. There is no direct evidence in the commit that this is a security bug, but inconsistent script validation between node implementations can lead to chain splits or transaction relay problems.
Review that the P2A detection constants and behavior exactly match the Bitcoin Core implementation and any relevant BIP/specification. Run the new tests and cross-validate P2A transaction acceptance against Bitcoin Core to avoid consensus divergence. Monitor for any follow-up commits that refine the rules.
Security signals we found
Script validation logic changed for a new witness program type
P2A outputs treated as anyone-can-spend with unconditional success path
Guard added to prevent P2SH-wrapped P2A from taking the success path
Test cases added for valid and invalid P2A spends
Potential consensus/standardness divergence risk if P2A rules differ from Bitcoin Core
Evidence from the diff
The patch adds IsPayToAnchorWitnessProgram() to detect witness version 1 programs whose 2-byte payload equals 0x4e73. In verifyWitnessProgram(), it adds a dedicated case so that P2A outputs succeed execution regardless of witness content, provided they are native witness programs (not wrapped in P2SH, checked via !vm.bip16). It also adds tests covering empty witness, non-empty witness, and non-empty sigscript (the last expected to fail with ErrWitnessMalleated). The change is a protocol-compatibility update rather than a memory-safety or cryptographic bug fix.
Changed components
txscript/engine.gotxscript/engine_p2a_test.gobtcd script validation / execution engineInspect captured patch +130 / −4
diff --git a/txscript/engine.go b/txscript/engine.go
index 95bc55f..9ce3a9f 100644
--- a/txscript/engine.go
+++ b/txscript/engine.go
@@ -542,6 +542,13 @@ func (vm *Engine) isWitnessVersionActive(version uint) bool {
return vm.witnessProgram != nil && uint(vm.witnessVersion) == version
}
+// IsPayToAnchorWitnessProgram returns true if the witness version and program
+// correspond to a pay-to-anchor output.
+func IsPayToAnchorWitnessProgram(witnessVersion int, witnessProgram []byte) bool {
+ return witnessVersion == 1 && len(witnessProgram) == 2 &&
+ bytes.Equal(witnessProgram, []byte{0x4e, 0x73})
+}
+
// witnessProgramAcceptStack collapses the data stack down to a single element
// for witness programs that succeed without inner script execution. The
// running pkScript must have left a truthy top item; the stack is then reduced
@@ -795,18 +802,30 @@ func (vm *Engine) verifyWitnessProgram(witness wire.TxWitness) error {
vm.SetStack(witness[:len(witness)-2])
}
+ // Pay-to-anchor (P2A) outputs are special anyone-can-spend outputs.
+ // They only work as native witness programs, not wrapped in P2SH.
+ case IsPayToAnchorWitnessProgram(
+ vm.witnessVersion, vm.witnessProgram,
+ ) && !vm.bip16:
+ // P2A spending always succeeds regardless of witness content.
+ if err := vm.witnessProgramAcceptStack(); err != nil {
+ return err
+ }
+
case vm.hasFlag(ScriptVerifyDiscourageUpgradeableWitnessProgram):
errStr := fmt.Sprintf("new witness program versions "+
"invalid: %v", vm.witnessProgram)
- return scriptError(ErrDiscourageUpgradableWitnessProgram, errStr)
+ return scriptError(
+ ErrDiscourageUpgradableWitnessProgram, errStr,
+ )
+
default:
if err := vm.witnessProgramAcceptStack(); err != nil {
return err
}
}
- // TODO(roasbeef): other sanity checks here
switch {
// In addition to the normal script element size limits, taproot also
@@ -933,8 +952,10 @@ func (vm *Engine) CheckErrorCondition(finalScript bool) error {
cleanStackActive := vm.hasFlag(ScriptVerifyCleanStack) || vm.taprootCtx != nil
if finalScript && cleanStackActive && vm.dstack.Depth() != 1 {
- str := fmt.Sprintf("stack must contain exactly one item (contains %d)",
- vm.dstack.Depth())
+ str := fmt.Sprintf(
+ "stack must contain exactly one item (contains %d)",
+ vm.dstack.Depth(),
+ )
return scriptError(ErrCleanStack, str)
} else if vm.dstack.Depth() < 1 {
return scriptError(ErrEmptyStack,
diff --git a/txscript/engine_p2a_test.go b/txscript/engine_p2a_test.go
new file mode 100644
index 0000000..7d2b227
--- /dev/null
+++ b/txscript/engine_p2a_test.go
@@ -0,0 +1,105 @@
+package txscript
+
+import (
+ "testing"
+
+ "github.com/btcsuite/btcd/wire"
+ "github.com/stretchr/testify/require"
+)
+
+// TestP2ASpending tests that pay-to-anchor outputs can be spent correctly.
+func TestP2ASpending(t *testing.T) {
+ tests := []struct {
+ name string
+ scriptPubKey []byte
+ witness wire.TxWitness
+ sigScript []byte
+ shouldPass bool
+ errCode ErrorCode
+ }{
+ {
+ name: "valid P2A spend with empty witness and " +
+ "sigscript",
+ scriptPubKey: PayToAnchorScript,
+ witness: wire.TxWitness{},
+ sigScript: []byte{},
+ shouldPass: true,
+ },
+ {
+ name: "P2A with non-empty witness should " +
+ "succeed",
+ scriptPubKey: PayToAnchorScript,
+ witness: wire.TxWitness{[]byte{0x01}},
+ sigScript: []byte{},
+ shouldPass: true,
+ },
+ {
+ name: "P2A with non-empty sigscript should fail",
+ scriptPubKey: PayToAnchorScript,
+ witness: wire.TxWitness{},
+ sigScript: []byte{0x01, 0x02},
+ shouldPass: false,
+ errCode: ErrWitnessMalleated,
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ // Create a transaction with the P2A output being spent.
+ prevTx := wire.NewMsgTx(2)
+ prevTx.AddTxOut(&wire.TxOut{
+ Value: 1000,
+ PkScript: test.scriptPubKey,
+ })
+ prevTxHash := prevTx.TxHash()
+
+ // Create the spending transaction.
+ tx := wire.NewMsgTx(2)
+ tx.AddTxIn(&wire.TxIn{
+ PreviousOutPoint: wire.OutPoint{
+ Hash: prevTxHash,
+ Index: 0,
+ },
+ SignatureScript: test.sigScript,
+ Witness: test.witness,
+ })
+ tx.AddTxOut(&wire.TxOut{
+ Value: 900,
+ PkScript: []byte{OP_TRUE},
+ })
+
+ // Create the script engine.
+ vm, err := NewEngine(
+ test.scriptPubKey, tx, 0,
+ StandardVerifyFlags, nil,
+ nil, 1000, nil,
+ )
+ if err != nil {
+ if test.errCode != 0 {
+ require.True(t, IsErrorCode(err, test.errCode))
+ return
+ } else {
+ require.NoError(t, err)
+ }
+ }
+
+ if test.shouldPass {
+ if err != nil {
+ t.Fatalf("NewEngine failed "+
+ "unexpectedly: %v", err)
+ }
+
+ err = vm.Execute()
+ if err != nil {
+ t.Fatalf("Execute failed unexpectedly: %v", err)
+ }
+ } else {
+ if err == nil {
+ t.Fatal("Expected NewEngine to fail, but it succeeded")
+ }
+
+ require.True(t, IsErrorCode(err, test.errCode))
+ }
+ })
+ }
+}
Why this scored 59/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.