Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions integration/rawtx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,177 @@ func decodeHex(t *testing.T, txHex string) *wire.MsgTx {

return tx.MsgTx()
}

// TestLargeOPReturnMempool tests that large OP_RETURN transactions (up to 100KB)
// are accepted into the mempool. This validates the Bitcoin Core v30 change that
// removes the 80-byte OP_RETURN limit.
func TestLargeOPReturnMempool(t *testing.T) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bitcoin Core 30.0 will also allow multiple OP_RETURN outputs on transactions. I’m not sure whether BTCD ever restricted transactions to a single one, but another test that adds two small OP_RETURN outputs would demonstrate that BTCD is compatible, or reveal that additional changes would need to be made to make it compatible.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@murchandamus It's enforced atm see

btcd/mempool/policy.go

Lines 368 to 373 in b7d0706

// A standard transaction must not have more than one output script that
// only carries data.
if numNullDataOutputs > 1 {
str := "more than one transaction output in a nulldata script"
return txRuleError(wire.RejectNonstandard, str)
}

Looking into it now

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

t.Parallel()

// Create a single node for testing mempool acceptance.
// Use --rejectnonstd to enforce standardness rules (including maxStandardTxWeight).
btcdCfg := []string{"--rejectnonstd", "--debuglevel=debug"}
node, err := rpctest.New(&chaincfg.SimNetParams, nil, btcdCfg, "")
require.NoError(t, err)

// Setup the node with mature coinbase outputs.
require.NoError(t, node.SetUp(true, 100))
t.Cleanup(func() {
require.NoError(t, node.TearDown())
})

testCases := []struct {
name string
dataSize int
shouldSucceed bool
failureReason string
failsScriptCreation bool
}{
{
name: "75KB OP_RETURN (well over old 80 byte/520 byte limits)",
dataSize: 75000,
shouldSucceed: true,
},
{
name: "100KB OP_RETURN (at MaxDataCarrierSize limit)",
dataSize: 100000,
shouldSucceed: false,
failureReason: "transaction size exceeds maxStandardTxWeight (100,000 vbytes)",
},
{
name: "101KB OP_RETURN (exceeds MaxDataCarrierSize)",
dataSize: 101000,
shouldSucceed: false,
failureReason: "data exceeds MaxDataCarrierSize",
failsScriptCreation: true,
},
}

// Run single OP_RETURN tests
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
testSingleOPReturn(t, node, tc)
})
}

// Run multiple OP_RETURN test
t.Run("Multiple OP_RETURNs", func(t *testing.T) {
testMultipleOPReturns(t, node)
})
}

func testSingleOPReturn(t *testing.T, node *rpctest.Harness, tc struct {
name string
dataSize int
shouldSucceed bool
failureReason string
failsScriptCreation bool
}) {
require := require.New(t)

// Create large OP_RETURN script.
largeData := make([]byte, tc.dataSize)
for i := range largeData {
largeData[i] = byte(i % 256)
}

opReturnScript, err := txscript.NullDataScript(largeData)

// If we expect script creation to fail (e.g., data > MaxDataCarrierSize), verify and return.
if tc.failsScriptCreation {
require.Error(err, "NullDataScript should fail for data exceeding MaxDataCarrierSize")
t.Logf("✓ NullDataScript correctly rejected (%s): %v", tc.failureReason, err)
return
}

require.NoError(err, "NullDataScript should succeed for data under MaxDataCarrierSize")

// Create a transaction with OP_RETURN output using the harness wallet.
// The wallet will handle signing and input selection.
opReturnOutput := &wire.TxOut{
Value: 0,
PkScript: opReturnScript,
}

// Create transaction with the OP_RETURN output.
// The wallet automatically adds inputs and a change output if needed.
tx, err := node.CreateTransaction([]*wire.TxOut{opReturnOutput}, 10, true)
require.NoError(err)

// Log the actual transaction size.
txSize := tx.SerializeSize()
txWeight := txSize * 4 // Non-segwit: weight = size * 4
t.Logf("Transaction size: %d bytes, weight: %d (limit: 400000)", txSize, txWeight)

// Submit to the mempool.
txHash, err := node.Client.SendRawTransaction(tx, true)

if tc.shouldSucceed {
require.NoError(err, "Large OP_RETURN tx should be accepted")
t.Logf("✓ Large OP_RETURN tx (%d bytes data) accepted: %s", tc.dataSize, txHash)

// Verify it's in the mempool.
mempool, err := node.Client.GetRawMempool()
require.NoError(err)
require.Contains(mempool, txHash, "Transaction should be in mempool")
} else {
require.Error(err, "Transaction should be rejected: %s", tc.failureReason)
t.Logf("✓ Transaction correctly rejected (%s): %v", tc.failureReason, err)
}
}

func testMultipleOPReturns(t *testing.T, node *rpctest.Harness) {
require := require.New(t)

// Create multiple large OP_RETURN outputs totaling ~75KB.
// This tests that multiple OP_RETURNs work even with large data sizes.
data1 := make([]byte, 25000)
data2 := make([]byte, 25000)
data3 := make([]byte, 25000)

for i := range data1 {
data1[i] = byte(i % 256)
}
for i := range data2 {
data2[i] = byte((i + 100) % 256)
}
for i := range data3 {
data3[i] = byte((i + 200) % 256)
}

script1, err := txscript.NullDataScript(data1)
require.NoError(err)

script2, err := txscript.NullDataScript(data2)
require.NoError(err)

script3, err := txscript.NullDataScript(data3)
require.NoError(err)

// Create transaction with three OP_RETURN outputs.
outputs := []*wire.TxOut{
{Value: 0, PkScript: script1},
{Value: 0, PkScript: script2},
{Value: 0, PkScript: script3},
}

tx, err := node.CreateTransaction(outputs, 10, true)
require.NoError(err)

txSize := tx.SerializeSize()
txWeight := txSize * 4
totalDataSize := len(data1) + len(data2) + len(data3)
t.Logf("Multiple OP_RETURN transaction: %d bytes (weight: %d, data: %d bytes)",
txSize, txWeight, totalDataSize)

// Submit to the mempool.
txHash, err := node.Client.SendRawTransaction(tx, true)
require.NoError(err, "Transaction with multiple OP_RETURNs should be accepted")
t.Logf("✓ Transaction with 3 OP_RETURNs (~75KB total data) accepted: %s", txHash)

// Verify it's in the mempool.
mempool, err := node.Client.GetRawMempool()
require.NoError(err)
require.Contains(mempool, txHash, "Transaction should be in mempool")
}
9 changes: 3 additions & 6 deletions mempool/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,12 +365,9 @@ func CheckTransactionStandard(tx *btcutil.Tx, height int32,
}
}

// A standard transaction must not have more than one output script that
// only carries data.
if numNullDataOutputs > 1 {
str := "more than one transaction output in a nulldata script"
return txRuleError(wire.RejectNonstandard, str)
}
// Note: Multiple OP_RETURN outputs are now allowed as standard.
// Each output is still subject to MaxDataCarrierSize and the
// overall transaction remains subject to maxStandardTxWeight.

return nil
}
Expand Down
151 changes: 148 additions & 3 deletions mempool/policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ func TestCheckTransactionStandard(t *testing.T) {
code: wire.RejectNonstandard,
},
{
name: "More than one nulldata output",
name: "Multiple nulldata outputs (now standard)",
tx: wire.MsgTx{
Version: 1,
TxIn: []*wire.TxIn{&dummyTxIn},
Expand All @@ -430,8 +430,7 @@ func TestCheckTransactionStandard(t *testing.T) {
LockTime: 0,
},
height: 300000,
isStandard: false,
code: wire.RejectNonstandard,
isStandard: true,
},
{
name: "Dust output",
Expand Down Expand Up @@ -462,6 +461,152 @@ func TestCheckTransactionStandard(t *testing.T) {
height: 300000,
isStandard: true,
},
{
name: "Large nulldata output with 75KB data (standard after v30)",
tx: func() wire.MsgTx {
// Create a large OP_RETURN with 75KB of data
// This is well over previous limits (80 bytes, 520 bytes MaxScriptElementSize)
// but under the new 100KB MaxDataCarrierSize limit
largeData := make([]byte, 75000)
for i := range largeData {
largeData[i] = byte(i % 256)
}
pkScript, err := txscript.NullDataScript(largeData)
if err != nil {
t.Fatalf("NullDataScript: unexpected error: %v", err)
}
return wire.MsgTx{
Version: 1,
TxIn: []*wire.TxIn{&dummyTxIn},
TxOut: []*wire.TxOut{{
Value: 0,
PkScript: pkScript,
}},
LockTime: 0,
}
}(),
height: 300000,
isStandard: true,
},
{
name: "Three nulldata outputs totaling ~75KB",
tx: func() wire.MsgTx {
// Create three large OP_RETURNs totaling approximately 75KB
data1 := make([]byte, 25000)
data2 := make([]byte, 25000)
data3 := make([]byte, 25000)
for i := range data1 {
data1[i] = byte(i % 256)
}
for i := range data2 {
data2[i] = byte((i + 100) % 256)
}
for i := range data3 {
data3[i] = byte((i + 200) % 256)
}

pkScript1, _ := txscript.NullDataScript(data1)
pkScript2, _ := txscript.NullDataScript(data2)
pkScript3, _ := txscript.NullDataScript(data3)

return wire.MsgTx{
Version: 1,
TxIn: []*wire.TxIn{&dummyTxIn},
TxOut: []*wire.TxOut{{
Value: 0,
PkScript: pkScript1,
}, {
Value: 0,
PkScript: pkScript2,
}, {
Value: 0,
PkScript: pkScript3,
}},
LockTime: 0,
}
}(),
height: 300000,
isStandard: true,
},
{
name: "Mixed outputs with two large OP_RETURNs and payment",
tx: func() wire.MsgTx {
// Create two large OP_RETURNs plus a payment output
data1 := make([]byte, 37000)
data2 := make([]byte, 37000)
for i := range data1 {
data1[i] = byte(i % 256)
}
for i := range data2 {
data2[i] = byte((i + 150) % 256)
}

pkScript1, _ := txscript.NullDataScript(data1)
pkScript2, _ := txscript.NullDataScript(data2)

return wire.MsgTx{
Version: 1,
TxIn: []*wire.TxIn{&dummyTxIn},
TxOut: []*wire.TxOut{{
Value: 100000000, // 1 BTC payment
PkScript: dummyPkScript,
}, {
Value: 0,
PkScript: pkScript1,
}, {
Value: 0,
PkScript: pkScript2,
}},
LockTime: 0,
}
}(),
height: 300000,
isStandard: true,
},
{
name: "Multiple OP_RETURNs with combined size over 100KB (but each under limit)",
tx: func() wire.MsgTx {
// Each OP_RETURN is under 100KB individually, but combined they exceed it.
// Each is 60KB, total is 180KB of OP_RETURN data.
// This tests that each output is independently subject to MaxDataCarrierSize,
// and the transaction as a whole is limited by maxStandardTxWeight.
data1 := make([]byte, 60000)
data2 := make([]byte, 60000)
data3 := make([]byte, 60000)
for i := range data1 {
data1[i] = byte(i % 256)
}
for i := range data2 {
data2[i] = byte((i + 100) % 256)
}
for i := range data3 {
data3[i] = byte((i + 200) % 256)
}

pkScript1, _ := txscript.NullDataScript(data1)
pkScript2, _ := txscript.NullDataScript(data2)
pkScript3, _ := txscript.NullDataScript(data3)

return wire.MsgTx{
Version: 1,
TxIn: []*wire.TxIn{&dummyTxIn},
TxOut: []*wire.TxOut{{
Value: 0,
PkScript: pkScript1,
}, {
Value: 0,
PkScript: pkScript2,
}, {
Value: 0,
PkScript: pkScript3,
}},
LockTime: 0,
}
}(),
height: 300000,
isStandard: false, // Rejected due to exceeding maxStandardTxWeight
code: wire.RejectNonstandard,
},
}

pastMedianTime := time.Now()
Expand Down
18 changes: 12 additions & 6 deletions txscript/scriptbuilder.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,13 +199,19 @@ func (b *ScriptBuilder) addData(data []byte) *ScriptBuilder {
return b
}

// AddFullData should not typically be used by ordinary users as it does not
// include the checks which prevent data pushes larger than the maximum allowed
// sizes which leads to scripts that can't be executed. This is provided for
// testing purposes such as regression tests where sizes are intentionally made
// larger than allowed.
// AddFullData pushes the passed data to the end of the script without
// enforcing MaxScriptElementSize limits. It automatically chooses canonical
// opcodes depending on the length of the data.
//
// Use AddData instead.
// This function bypasses the 520-byte MaxScriptElementSize limit and is
// intended for:
//
// 1. Creating OP_RETURN outputs with data larger than 520 bytes, since they
// are provably unspendable and never executed.
// 2. Testing purposes where scripts need to intentionally exceed limits.
//
// For regular data pushes that will be executed, use AddData instead, which
// enforces size limits to ensure the resulting script can be executed.
func (b *ScriptBuilder) AddFullData(data []byte) *ScriptBuilder {
if b.err != nil {
return b
Expand Down
Loading