From f9bb6776c537286ac931cb28ff978cca3dd62be2 Mon Sep 17 00:00:00 2001 From: Eric Grill Date: Wed, 14 Jan 2026 07:27:42 +0000 Subject: [PATCH] txscript: allow multiple push operations for OP_RETURN This change updates the isNullDataScript function to allow multiple data push operations after OP_RETURN, matching Bitcoin Core's behavior as of v0.12.0 which removed the single push restriction. Previously, btcd only accepted OP_RETURN followed by a single data push of up to 80 bytes. This was more restrictive than Bitcoin Core's implementation. Changes: - Add MaxNullDataScriptSize constant (10000 bytes) for total script size limit - Keep MaxDataCarrierSize at 80 bytes for backwards compatibility with NullDataScript function callers - Update isNullDataScript to use IsPushOnlyScript to validate that all opcodes after OP_RETURN are valid data pushes - Enforce limit on total script size instead of individual push size - Update and add test cases for the new behavior This allows protocols like the Metaprotocol or Counterparty to use OP_RETURN outputs with multiple data pushes. Fixes #2305 Co-Authored-By: Claude Opus 4.5 --- txscript/standard.go | 31 +++++++++++++------ txscript/standard_test.go | 65 ++++++++++++++++++++++++++++++++++----- 2 files changed, 80 insertions(+), 16 deletions(-) diff --git a/txscript/standard.go b/txscript/standard.go index 809a900a2a..07bd6fdd89 100644 --- a/txscript/standard.go +++ b/txscript/standard.go @@ -14,9 +14,17 @@ import ( const ( // MaxDataCarrierSize is the maximum number of bytes allowed in pushed - // data to be considered a nulldata transaction + // data to be considered a nulldata transaction when using the + // NullDataScript function. This is kept at 80 for backwards compatibility. MaxDataCarrierSize = 80 + // MaxNullDataScriptSize is the maximum total size in bytes of a nulldata + // script (including OP_RETURN and all push opcodes) to be considered + // standard. This was increased from 83 bytes to 10000 bytes to match + // Bitcoin Core's relaxed policy as of v0.12.0, which removed the single + // push restriction and enforces limits on total script size instead. + MaxNullDataScriptSize = 10000 + // StandardVerifyFlags are the script flags which are used when // executing transaction scripts to enforce additional checks which // are required for the script to be considered standard. These checks @@ -502,10 +510,12 @@ func isNullDataScript(scriptVersion uint16, script []byte) bool { } // A null script is of the form: - // OP_RETURN + // OP_RETURN // - // Thus, it can either be a single OP_RETURN or an OP_RETURN followed by a - // data push up to MaxDataCarrierSize bytes. + // It can be a single OP_RETURN or an OP_RETURN followed by any number of + // data pushes. The total script size must not exceed MaxNullDataScriptSize. + // This matches Bitcoin Core's behavior as of v0.12.0 which removed the + // single push restriction. // The script can't possibly be a null data script if it doesn't start // with OP_RETURN. Fail fast to avoid more work below. @@ -513,16 +523,19 @@ func isNullDataScript(scriptVersion uint16, script []byte) bool { return false } + // Enforce the total script size limit. + if len(script) > MaxNullDataScriptSize { + return false + } + // Single OP_RETURN. if len(script) == 1 { return true } - // OP_RETURN followed by data push up to MaxDataCarrierSize bytes. - tokenizer := MakeScriptTokenizer(scriptVersion, script[1:]) - return tokenizer.Next() && tokenizer.Done() && - (IsSmallInt(tokenizer.Opcode()) || tokenizer.Opcode() <= OP_PUSHDATA4) && - len(tokenizer.Data()) <= MaxDataCarrierSize + // OP_RETURN followed by push-only data. All opcodes after OP_RETURN + // must be data pushes (OP_0 through OP_16 and direct/PUSHDATA pushes). + return IsPushOnlyScript(script[1:]) } // scriptType returns the type of the script being inspected from the known diff --git a/txscript/standard_test.go b/txscript/standard_test.go index 4993a65260..22f87a4754 100644 --- a/txscript/standard_test.go +++ b/txscript/standard_test.go @@ -30,6 +30,30 @@ func mustParseShortForm(script string) []byte { return s } +// buildLargeNullDataScript creates a null data script with the specified +// number of data bytes. Uses PUSHDATA2 for larger data sizes. +func buildLargeNullDataScript(dataSize int) string { + // For PUSHDATA2, the format is: + // OP_RETURN OP_PUSHDATA2 <2-byte little-endian length> + // Total overhead: 1 (OP_RETURN) + 1 (OP_PUSHDATA2) + 2 (length) = 4 bytes + // But we're using hex representation in the test format. + + // Build the data bytes (all zeros for simplicity). + data := make([]byte, dataSize) + + // Build the script manually: + // OP_RETURN (0x6a) + OP_PUSHDATA2 (0x4d) + length (2 bytes LE) + data + script := make([]byte, 1+1+2+dataSize) + script[0] = OP_RETURN + script[1] = OP_PUSHDATA2 + script[2] = byte(dataSize & 0xff) + script[3] = byte((dataSize >> 8) & 0xff) + copy(script[4:], data) + + // Convert to hex string for the test framework + return hex.EncodeToString(script) +} + // newAddressPubKey returns a new btcutil.AddressPubKey from the provided // serialized public key. It panics if an error occurs. This is only used in // the tests as a helper since the only way it can fail is if there is an error @@ -1022,20 +1046,47 @@ var scriptClassTests = []struct { class: NullDataTy, }, { - // Nulldata with more than max allowed data to be considered - // standard (so therefore nonstandard) - name: "nulldata exceed max standard push", + // Nulldata with 81 bytes of data (now allowed since the limit + // is on total script size, not individual push size). + name: "nulldata 81-byte push", script: "RETURN PUSHDATA1 0x51 0x046708afdb0fe5548271967f1a67" + "130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef3" + "046708afdb0fe5548271967f1a67130b7105cd6a828e03909a67" + "962e0ea1f61deb649f6bc3f4cef308", - class: NonStandardTy, + class: NullDataTy, }, { - // Almost nulldata, but add an additional opcode after the data - // to make it nonstandard. - name: "almost nulldata", + // Nulldata with multiple data pushes (now allowed as of Bitcoin + // Core v0.12.0 which removed the single push restriction). + name: "nulldata multiple pushes", script: "RETURN 4 TRUE", + class: NullDataTy, + }, + { + // Nulldata with many data pushes using various push opcodes. + name: "nulldata many pushes", + script: "RETURN 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 " + + "DATA_8 0x0102030405060708", + class: NullDataTy, + }, + { + // Nulldata with non-push opcode after OP_RETURN (nonstandard). + name: "nulldata non-push opcode", + script: "RETURN DATA_4 0x01020304 DUP", + class: NonStandardTy, + }, + { + // Nulldata with large data push (9996 bytes, within 10000 byte limit). + // Total: 1 (OP_RETURN) + 1 (OP_PUSHDATA2) + 2 (length) + 9996 (data) = 10000 + name: "nulldata large within limit", + script: buildLargeNullDataScript(9996), + class: NullDataTy, + }, + { + // Nulldata exceeding the 10000 byte script size limit. + // Total: 1 (OP_RETURN) + 1 (OP_PUSHDATA2) + 2 (length) + 9997 (data) = 10001 + name: "nulldata exceeds script size limit", + script: buildLargeNullDataScript(9997), class: NonStandardTy, },