What changed, and why it matters
This commit adds a new mempool policy rule for a special Bitcoin output type called Pay-to-Anchor (P2A). It ensures that transactions spending P2A outputs are only accepted into the memory pool if they have empty signature and witness data. It also adds tests confirming the dust threshold for these outputs. There is no direct evidence in the commit that this fixes an active security vulnerability; it appears to be a policy implementation aligning btcd with expected Bitcoin network behavior.
Review as a normal policy/standardness change. No immediate security response appears necessary based solely on this commit. If P2A support is being deployed, ensure full consensus and mempool policy alignment with Bitcoin Core/BIP 433 is verified through broader release notes or upstream documentation.
Security signals we found
New mempool policy rule restricting how P2A outputs may be spent
Rejects non-standard P2A spends with non-empty signature script or witness
Refactoring to improve testability of input standardness checks
Addition of unit tests for P2A dust and spend standardness
Evidence from the diff
The patch introduces P2A-specific standardness checks in mempool/policy.go. When an input spends a P2A output (identified by txscript.IsPayToAnchorScript), the code now rejects the transaction unless both SignatureScript and Witness are empty. It refactors checkInputsStandard into checkInputsStandardWithView using small utxoEntry/utxoView interfaces to enable unit testing with mocks. Tests are added for dust threshold calculation and transaction standardness for P2A outputs, plus a new TestP2ASpendingStandardness covering empty/non-empty script/witness combinations.
Changed components
mempool/policy.gomempool/policy_test.goInspect captured patch +332 / −2
diff --git a/mempool/policy.go b/mempool/policy.go
index 862767d..dd27b4b 100644
--- a/mempool/policy.go
+++ b/mempool/policy.go
@@ -79,6 +79,29 @@ func calcMinRequiredTxRelayFee(serializedSize int64, minRelayTxFee btcutil.Amoun
return minFee
}
+// utxoEntry is an interface that provides access to UTXO data needed for
+// input standardness checks.
+type utxoEntry interface {
+ PkScript() []byte
+}
+
+// utxoView is an interface that provides access to UTXOs needed for
+// input standardness checks.
+type utxoView interface {
+ LookupEntry(wire.OutPoint) utxoEntry
+}
+
+// utxoViewpointAdapter wraps a blockchain.UtxoViewpoint to implement the
+// utxoView interface.
+type utxoViewpointAdapter struct {
+ view *blockchain.UtxoViewpoint
+}
+
+// LookupEntry returns the entry for a given outpoint.
+func (u *utxoViewpointAdapter) LookupEntry(op wire.OutPoint) utxoEntry {
+ return u.view.LookupEntry(op)
+}
+
// checkInputsStandard performs a series of checks on a transaction's inputs
// to ensure they are "standard". A standard transaction input within the
// context of this function is one whose referenced public key script is of a
@@ -90,6 +113,12 @@ func calcMinRequiredTxRelayFee(serializedSize int64, minRelayTxFee btcutil.Amoun
// accurately and concisely via the txscript.ScriptVerifyCleanStack and
// txscript.ScriptVerifySigPushOnly flags.
func checkInputsStandard(tx *btcutil.Tx, utxoView *blockchain.UtxoViewpoint) error {
+ return checkInputsStandardWithView(tx, &utxoViewpointAdapter{view: utxoView})
+}
+
+// checkInputsStandardWithView performs input standardness checks using the
+// utxoView interface.
+func checkInputsStandardWithView(tx *btcutil.Tx, utxoView utxoView) error {
// NOTE: The reference implementation also does a coinbase check here,
// but coinbases have already been rejected prior to calling this
// function so no need to recheck.
@@ -100,6 +129,28 @@ func checkInputsStandard(tx *btcutil.Tx, utxoView *blockchain.UtxoViewpoint) err
// function.
entry := utxoView.LookupEntry(txIn.PreviousOutPoint)
originPkScript := entry.PkScript()
+
+ // Check standardness for P2A inputs. P2A outputs must be spent
+ // with empty signature script and empty witness.
+ if txscript.IsPayToAnchorScript(originPkScript) {
+ if len(txIn.SignatureScript) != 0 {
+ str := fmt.Sprintf("transaction input "+
+ "#%d spends P2A output with non-empty "+
+ "signature script", i)
+ return txRuleError(wire.RejectNonstandard, str)
+ }
+ if len(txIn.Witness) != 0 {
+ str := fmt.Sprintf("transaction "+
+ "input #%d spends P2A output with "+
+ "non-empty witness", i)
+ return txRuleError(wire.RejectNonstandard, str)
+ }
+
+ // P2A inputs are standard with empty sigscript and
+ // witness.
+ continue
+ }
+
switch txscript.GetScriptClass(originPkScript) {
case txscript.ScriptHashTy:
numSigOps := txscript.GetPreciseSigOpCount(
@@ -175,6 +226,12 @@ func checkPkScriptStandard(pkScript []byte, scriptClass txscript.ScriptClass) er
// GetDustThreshold calculates the dust limit for a *wire.TxOut by taking the
// size of a typical spending transaction and multiplying it by 3 to account
// for the minimum dust relay fee of 3000sat/kvb.
+//
+// Pay-to-Anchor outputs are not special-cased here: because P2A is a witness
+// program with a 4-byte script, the generic witness path below yields a
+// threshold of 240 satoshis at the default 1000 sat/kvB relay fee, matching
+// the BIP 433 default. At other relay fees the threshold scales accordingly,
+// which is the same behavior as Bitcoin Core's GetDustThreshold.
func GetDustThreshold(txOut *wire.TxOut) int64 {
// The total serialized size consists of the output and the associated
// input script to redeem it. Since there is no input script
diff --git a/mempool/policy_test.go b/mempool/policy_test.go
index 29c0956..aaa21bd 100644
--- a/mempool/policy_test.go
+++ b/mempool/policy_test.go
@@ -15,6 +15,7 @@ import (
"github.com/btcsuite/btcd/chaincfg/chainhash"
"github.com/btcsuite/btcd/txscript"
"github.com/btcsuite/btcd/wire"
+ "github.com/stretchr/testify/mock"
)
// TestCalcMinRequiredTxRelayFee tests the calcMinRequiredTxRelayFee API.
@@ -265,6 +266,77 @@ func TestDust(t *testing.T) {
0, // no relay fee
true,
},
+ // P2A (Pay-to-Anchor) tests
+ {
+ "P2A with 239 sats (dust)",
+ wire.TxOut{
+ Value: 239,
+ PkScript: txscript.PayToAnchorScript,
+ },
+ 1000,
+ true,
+ },
+ {
+ "P2A with 240 sats (not dust)",
+ wire.TxOut{
+ Value: 240,
+ PkScript: txscript.PayToAnchorScript,
+ },
+ 1000,
+ false,
+ },
+ {
+ "P2A with 241 sats (not dust)",
+ wire.TxOut{
+ Value: 241,
+ PkScript: txscript.PayToAnchorScript,
+ },
+ 1000,
+ false,
+ },
+ {
+ // Any positive value escapes dust when the relay fee is
+ // zero, just like the generic dust path.
+ "P2A with 240 sats and zero relay fee (not dust)",
+ wire.TxOut{
+ Value: 240,
+ PkScript: txscript.PayToAnchorScript,
+ },
+ 0,
+ false,
+ },
+ {
+ // At zero relay fee the dust threshold collapses to
+ // zero, so a 239-sat P2A output is no longer dust.
+ "P2A with 239 sats and zero relay fee (not dust)",
+ wire.TxOut{
+ Value: 239,
+ PkScript: txscript.PayToAnchorScript,
+ },
+ 0,
+ false,
+ },
+ {
+ // At a 100x relay fee the threshold scales to 24000
+ // sats, so a 240-sat P2A output is dust under the
+ // configured policy. This matches bitcoind's behavior.
+ "P2A with 240 sats and high relay fee (dust)",
+ wire.TxOut{
+ Value: 240,
+ PkScript: txscript.PayToAnchorScript,
+ },
+ 100000,
+ true,
+ },
+ {
+ "P2A with 239 sats and high relay fee (dust)",
+ wire.TxOut{
+ Value: 239,
+ PkScript: txscript.PayToAnchorScript,
+ },
+ 100000,
+ true,
+ },
}
for _, test := range tests {
res := IsDust(&test.txOut, test.relayFee)
@@ -462,13 +534,99 @@ func TestCheckTransactionStandard(t *testing.T) {
height: 300000,
isStandard: true,
},
+ {
+ name: "P2A output with 240 sats (standard)",
+ tx: wire.MsgTx{
+ Version: 1,
+ TxIn: []*wire.TxIn{&dummyTxIn},
+ TxOut: []*wire.TxOut{{
+ Value: 240,
+ PkScript: txscript.PayToAnchorScript,
+ }},
+ LockTime: 0,
+ },
+ height: 300000,
+ isStandard: true,
+ },
+ {
+ name: "P2A output with 239 sats (dust)",
+ tx: wire.MsgTx{
+ Version: 1,
+ TxIn: []*wire.TxIn{&dummyTxIn},
+ TxOut: []*wire.TxOut{{
+ Value: 239,
+ PkScript: txscript.PayToAnchorScript,
+ }},
+ LockTime: 0,
+ },
+ height: 300000,
+ isStandard: false,
+ code: wire.RejectDust,
+ },
+ {
+ name: "P2A output with 1000 sats (standard)",
+ tx: wire.MsgTx{
+ Version: 1,
+ TxIn: []*wire.TxIn{&dummyTxIn},
+ TxOut: []*wire.TxOut{{
+ Value: 1000,
+ PkScript: txscript.PayToAnchorScript,
+ }},
+ LockTime: 0,
+ },
+ height: 300000,
+ isStandard: true,
+ },
+ {
+ name: "Multiple P2A outputs (standard)",
+ tx: wire.MsgTx{
+ Version: 1,
+ TxIn: []*wire.TxIn{&dummyTxIn},
+ TxOut: []*wire.TxOut{
+ {
+ Value: 250,
+ PkScript: txscript.PayToAnchorScript,
+ },
+ {
+ Value: 300,
+ PkScript: txscript.PayToAnchorScript,
+ },
+ {
+ Value: 500,
+ PkScript: txscript.PayToAnchorScript,
+ },
+ },
+ LockTime: 0,
+ },
+ height: 300000,
+ isStandard: true,
+ },
+ {
+ name: "P2A mixed with regular outputs (standard)",
+ tx: wire.MsgTx{
+ Version: 1,
+ TxIn: []*wire.TxIn{&dummyTxIn},
+ TxOut: []*wire.TxOut{
+ &dummyTxOut,
+ {
+ Value: 250,
+ PkScript: txscript.PayToAnchorScript,
+ },
+ },
+ LockTime: 0,
+ },
+ height: 300000,
+ isStandard: true,
+ },
}
pastMedianTime := time.Now()
for _, test := range tests {
// Ensure standardness is as expected.
- err := CheckTransactionStandard(btcutil.NewTx(&test.tx),
- test.height, pastMedianTime, DefaultMinRelayTxFee, 1)
+ err := CheckTransactionStandard(
+ btcutil.NewTx(&test.tx), test.height, pastMedianTime,
+ DefaultMinRelayTxFee, 1,
+ )
if err == nil && test.isStandard {
// Test passes since function returned standard for a
// transaction which is intended to be standard.
@@ -508,3 +666,118 @@ func TestCheckTransactionStandard(t *testing.T) {
}
}
}
+
+// mockUtxoEntry mocks the utxoEntry interface using testify/mock.
+type mockUtxoEntry struct {
+ mock.Mock
+}
+
+// PkScript returns the public key script.
+func (m *mockUtxoEntry) PkScript() []byte {
+ args := m.Called()
+ return args.Get(0).([]byte)
+}
+
+// mockUtxoView mocks the utxoView interface using testify/mock.
+type mockUtxoView struct {
+ mock.Mock
+}
+
+// LookupEntry returns the entry for a given outpoint.
+func (m *mockUtxoView) LookupEntry(op wire.OutPoint) utxoEntry {
+ args := m.Called(op)
+ if args.Get(0) == nil {
+ return nil
+ }
+ return args.Get(0).(utxoEntry)
+}
+
+// TestP2ASpendingStandardness tests that P2A outputs require empty witness
+// and empty signature script to be considered standard.
+func TestP2ASpendingStandardness(t *testing.T) {
+ // Create a previous transaction with a P2A output.
+ prevTxHash, _ := chainhash.NewHashFromStr("0101010101010101010101010101010101010101010101010101010101010101")
+ prevOut := wire.OutPoint{Hash: *prevTxHash, Index: 0}
+
+ // Create mocked UTXO entry and view.
+ mockEntry := new(mockUtxoEntry)
+ mockEntry.On("PkScript").Return(txscript.PayToAnchorScript)
+
+ mockView := new(mockUtxoView)
+ mockView.On("LookupEntry", prevOut).Return(mockEntry)
+
+ tests := []struct {
+ name string
+ sigScript []byte
+ witness wire.TxWitness
+ shouldFail bool
+ }{
+ {
+ name: "P2A with empty witness and empty sigscript (standard)",
+ sigScript: []byte{},
+ witness: wire.TxWitness{},
+ shouldFail: false,
+ },
+ {
+ name: "P2A with empty sigscript and non-empty witness (not standard)",
+ sigScript: []byte{},
+ witness: wire.TxWitness{[]byte{0x01}},
+ shouldFail: true,
+ },
+ {
+ name: "P2A with non-empty sigscript (not standard)",
+ sigScript: []byte{0x01, 0x02},
+ witness: wire.TxWitness{},
+ shouldFail: true,
+ },
+ {
+ name: "P2A with both non-empty (not standard)",
+ sigScript: []byte{0x01},
+ witness: wire.TxWitness{[]byte{0x02}},
+ shouldFail: true,
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ // Create a transaction spending the P2A output.
+ tx := wire.NewMsgTx(2)
+ tx.AddTxIn(&wire.TxIn{
+ PreviousOutPoint: prevOut,
+ SignatureScript: test.sigScript,
+ Witness: test.witness,
+ })
+ // Add a dummy output.
+ dummyPkScript := []byte{
+ txscript.OP_DUP,
+ txscript.OP_HASH160,
+ txscript.OP_DATA_20,
+ }
+ dummyPkScript = append(dummyPkScript, make([]byte, 20)...)
+ dummyPkScript = append(dummyPkScript,
+ txscript.OP_EQUALVERIFY, txscript.OP_CHECKSIG)
+
+ tx.AddTxOut(&wire.TxOut{
+ Value: 900,
+ PkScript: dummyPkScript,
+ })
+
+ btcTx := btcutil.NewTx(tx)
+ err := checkInputsStandardWithView(btcTx, mockView)
+
+ if test.shouldFail {
+ if err == nil {
+ t.Errorf("Expected error for P2A with non-empty witness/sigscript, got nil")
+ }
+ } else {
+ if err != nil {
+ t.Errorf("Unexpected error for valid P2A spend: %v", err)
+ }
+ }
+
+ // Verify that the mock was called.
+ mockView.AssertExpectations(t)
+ mockEntry.AssertExpectations(t)
+ })
+ }
+}
Why this scored 32/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.