validation: throw when a compressed script can't be decompressed #36280

pull furszy wants to merge 2 commits into bitcoin:master from furszy:2026_compression_throw changing 3 files +96 −9
  1. furszy commented at 8:49 PM on September 16, 2026: member

    ScriptCompression::Unser ignores the DecompressScript return value and leaves the script empty on failure, without notifying upper layers about the error. In the worst-case scenario, which is currently only reachable through corruption, an unspendable off-curve P2PK turns into an anyone-can-spend empty script. Given that, properly throwing the error to prevent any future mistakes from happening seems the sanest path to follow.

    Aside from that, added coverage for a P2PK script with an uncompressed pubkey that has a valid X but a wrong Y, which makes it unspendable. Checking we store such scripts raw and never encode them as 0x04/0x05, as those encodings serialize X with only Y's parity (not the full Y), which would make decompression recompute Y from X and turn the unspendable script into a spendable one, which would not be good.

  2. test: script compression with off-curve and wrong-Y P2PK pubkeys
    Adds coverage for two cases:
    
    The existing compress_p2pk_scripts_not_on_curve test now also covers
    the ScriptCompression ser + unser round-trip, and checks the behavior
    of a compressed record that can't be decompressed.
    The worst-case scenario is a P2PK to an off-curve pubkey, which nobody
    can spend, that is turned into an anyone-can-spend script during
    decompression without throwing an error to upper layers.
    Note: this could only ever happen after corruption today, but better
    to add proper coverage for it, and add the missing error signaling
    (next commit).
    
    Then added coverage for a P2PK script with an uncompressed pubkey that
    has a valid X but a wrong Y, which makes it unspendable. Checking we store
    such scripts raw and never encode them as 0x04/0x05, as those encodings
    serialize X with only Y's parity (not the full Y), so decompress would
    recompute Y from X and turn the unspendable script into a spendable one,
    which would be bad.
    27c6a2c75b
  3. DrahtBot added the label Validation on Sep 16, 2026
  4. DrahtBot commented at 8:49 PM on September 16, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    <!--006a51241073e994b41acfe9ec718e94-->

    Code Coverage & Benchmarks

    For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/36280.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline and AI policy for information on the review process.

    Type Reviewers
    Concept ACK fjahr, purpleKarrot, l0rinc
    Approach ACK sedited

    If your review is incorrectly listed, please copy-paste <code>&lt;!--meta-tag:bot-skip--&gt;</code> into the comment that the bot should ignore.

    <!--174a7506f384e20aa4161008e828411d-->

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #36386 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/36386.svg"></sub> (compressor: oversized script decoding can leave a spendable script by l0rinc)
    • #36074 <sub><img src="https://drahtbot.space/ack_count/bitcoin/bitcoin/36074.svg"></sub> (scripted-diff: [test] Add util/check.h includes for assertions by maflcko)

    If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

  5. furszy force-pushed on Sep 16, 2026
  6. DrahtBot added the label CI failed on Sep 16, 2026
  7. sedited commented at 9:49 PM on September 16, 2026: contributor

    Approach ACK

  8. DrahtBot removed the label CI failed on Sep 16, 2026
  9. compressor: throw when compressed script can't be decompressed
    `ScriptCompression::Unser` ignores the `DecompressScript` return value and
    leaves the script empty on failure, without notifying upper layers about the error.
    In the worst-case scenario, which is currently only reachable through corruption,
    this turns an unspendable off-curve P2PK into an anyone-can-spend script.
    Given that, properly throwing the error to prevent any future mistakes from
    happening seems the sanest path to follow.
    
    The fuzz test has been expanded to cover more than a bare crash.
    5500521f8b
  10. furszy force-pushed on Sep 17, 2026
  11. in src/test/fuzz/script.cpp:132 in 5500521f8b
     133 |              CScript decompressed_script;
     134 | -            DecompressScript(decompressed_script, fuzzed_data_provider.ConsumeIntegral<unsigned int>(), compressed_script);
     135 | +            stream >> Using<ScriptCompression>(decompressed_script);
     136 | +            assert(decodable);
     137 | +            if (type < ScriptCompression::nSpecialScripts) {
     138 | +                assert(!decompressed_script.empty()); // future: check each type individually
    


    fjahr commented at 9:11 PM on September 26, 2026:

    Would you mind covering each type here already? It doesn't seem to be that complex and otherwise the test is weaker than it needs to be.


    l0rinc commented at 9:29 PM on September 29, 2026:

    Indeed, special-scripts were already checked to be decodable and should re-serialize to the same bytes, so a round-trip comparison would check that the script hasn't changed.

    <details> <summary>Require special records to re-encode unchanged</summary>

    diff --git a/src/test/fuzz/script.cpp b/src/test/fuzz/script.cpp
    index 14ec0a10a3..40a74cb008 100644
    --- a/src/test/fuzz/script.cpp
    +++ b/src/test/fuzz/script.cpp
    @@ -129,7 +129,7 @@ FUZZ_TARGET(script, .init = initialize_script)
                 stream >> Using<ScriptCompression>(decompressed_script);
                 assert(decodable);
                 if (type < ScriptCompression::nSpecialScripts) {
    -                assert(!decompressed_script.empty()); // future: check each type individually
    +                assert(std::ranges::equal(DataStream{} << Using<ScriptCompression>(decompressed_script), DataStream{} << VARINT(type) << std::span{payload}));
                 } else if (payload_size > MAX_SCRIPT_SIZE) {
                     assert(decompressed_script == CScript() << OP_RETURN);
                 } else {
    
    

    </details>

  12. in src/test/fuzz/script.cpp:103 in 5500521f8b
     129 | +        }
     130 | +
     131 | +        DataStream stream = DataStream{} << VARINT(type) << std::span{payload} << std::span{trailing};
     132 | +        try {
     133 |              CScript decompressed_script;
     134 | -            DecompressScript(decompressed_script, fuzzed_data_provider.ConsumeIntegral<unsigned int>(), compressed_script);
    


    fjahr commented at 9:13 PM on September 26, 2026:

    I guess this coverage wasn't that interesting but this is dropping the call of DecompressScript with different nSize.


    furszy commented at 11:50 PM on September 27, 2026:

    nSize is type (the compression format) and is still provided by the fuzzer and tested against the code. Diff is that we are providing it inside the data stream we feed to ScriptCompression instead of a direct func arg.

    Happy to know if you are seeing something else.


    fjahr commented at 7:51 AM on September 28, 2026:

    Sorry, my comment wasn't that clear, what has changed is that the fuzzer doesn't cover DecompressScript with nSize >5 anymore, but as I said, not that important coverage I guess.


    furszy commented at 3:29 PM on September 28, 2026:

    ah, last one checked against is 6 (nSpecialScripts), which check the raw script storage path (any number over 5 takes the same path). This was on purpose because once we go over the last encoding format, we start consuming bytes from the script, which could make nSize to underflow and end up with a huge allocation. <-- not true, I need caffeine.

  13. in src/test/compress_tests.cpp:11 in 5500521f8b
       7 | @@ -8,6 +8,7 @@
       8 |  #include <streams.h>
       9 |  #include <test/util/random.h>
      10 |  #include <test/util/setup_common.h>
      11 | +#include <test/util/common.h>
    


    fjahr commented at 9:14 PM on September 26, 2026:

    nit: sorting

  14. in src/test/compress_tests.cpp:195 in 27c6a2c75b outdated
     190 | +    // script spendable, which would be bad.
     191 | +    for (int i = 0; i < 1000; ++i) {
     192 | +        CKey key = GenerateRandomKey(/*compressed=*/false);
     193 | +        std::vector<unsigned char> pub = ToByteVector(key.GetPubKey());
     194 | +        pub[33] ^= 0x01; // corrupt Y without changing its parity
     195 | +        assert(!CPubKey(pub).IsFullyValid());
    


    fjahr commented at 9:23 PM on September 26, 2026:

    nit: maybe use BOOST_REQUIRE instead of assert?

  15. in src/test/compress_tests.cpp:191 in 27c6a2c75b outdated
     186 | +    // An uncompressed pubkey with a wrong Y for its X isn't on the curve, so such
     187 | +    // a P2PK is unspendable.
     188 | +    // Ensure it is stored raw and never encoded as 0x04/0x05. That encoding keeps X
     189 | +    // and only Y's parity, so it would decompress to the correct Y and make the
     190 | +    // script spendable, which would be bad.
     191 | +    for (int i = 0; i < 1000; ++i) {
    


    fjahr commented at 9:28 PM on September 26, 2026:

    Do we really need to check this 1000 times? If we do, then maybe this should rather be a fuzz test? For a unit test doing this once should suffice I think.

  16. fjahr commented at 9:29 PM on September 26, 2026: contributor

    Concept ACK

    Looks already pretty solid but I left a few, mostly minor comments.

  17. purpleKarrot commented at 10:24 AM on September 28, 2026: contributor

    In the worst-case scenario, which is currently only reachable through corruption,

    This does not sound like a valid use case for recoverable error handling to me. If this is a clear indication of corruption, it should be treated as such.

    Edit: I see that all decoding errors are reported through exceptions, which is not great. But it means that the error handling in this PR is aligned. Hence, ACK.

  18. in src/test/compress_tests.cpp:206 in 5500521f8b
     201 | +        BOOST_CHECK_EQUAL(done, false);
     202 | +
     203 | +        // Ensure serialize stores it raw (1-byte size prefix + 67, not 33 compressed)
     204 | +        // and unserialize gives it back unchanged
     205 | +        DataStream s = DataStream{} << Using<ScriptCompression>(script);
     206 | +        BOOST_CHECK_EQUAL(s.size(), 68U);
    


    l0rinc commented at 4:05 AM on September 29, 2026:

    Seems to me the off-curve and wrong-Y raw-storage assertions are the same, so could we extract them into a helper?

    <details> <summary>Share P2PK raw-storage checks</summary>

    diff --git a/src/test/compress_tests.cpp b/src/test/compress_tests.cpp
    index eba72eb3be..533a2a49de 100644
    --- a/src/test/compress_tests.cpp
    +++ b/src/test/compress_tests.cpp
    @@ -136,6 +136,16 @@ BOOST_AUTO_TEST_CASE(compress_script_to_uncompressed_pubkey_id)
         BOOST_CHECK_EQUAL(out[0], 0x04 | (script[65] & 0x01)); // least significant bit (lsb) of last char of pubkey is mapped into out[0]
     }
     
    +// ScriptCompression must encode the script in serialized_size bytes and decode it back unchanged
    +static void CheckScriptCompressionRoundTrip(const CScript& script, size_t serialized_size)
    +{
    +    DataStream s = DataStream{} << Using<ScriptCompression>(script);
    +    BOOST_CHECK_EQUAL(s.size(), serialized_size);
    +    CScript restored;
    +    s >> Using<ScriptCompression>(restored);
    +    BOOST_CHECK(restored == script);
    +}
    +
     BOOST_AUTO_TEST_CASE(compress_p2pk_scripts_not_on_curve)
     {
         XOnlyPubKey x_not_on_curve;
    @@ -175,10 +185,7 @@ BOOST_AUTO_TEST_CASE(compress_p2pk_scripts_not_on_curve)
         }
     
         // Check off-curve script serialization round-trips properly
    -    DataStream s = DataStream{} << Using<ScriptCompression>(script);
    -    CScript restored;
    -    s >> Using<ScriptCompression>(restored);
    -    BOOST_CHECK(restored == script);
    +    CheckScriptCompressionRoundTrip(script, 68);
     }
     
     BOOST_AUTO_TEST_CASE(compress_p2pk_wrong_y)
    @@ -202,11 +209,7 @@ BOOST_AUTO_TEST_CASE(compress_p2pk_wrong_y)
     
             // Ensure serialize stores it raw (1-byte size prefix + 67, not 33 compressed)
             // and unserialize gives it back unchanged
    -        DataStream s = DataStream{} << Using<ScriptCompression>(script);
    -        BOOST_CHECK_EQUAL(s.size(), 68U);
    -        CScript restored;
    -        s >> Using<ScriptCompression>(restored);
    -        BOOST_CHECK(restored == script);
    +        CheckScriptCompressionRoundTrip(script, 68);
         }
     }
     
    
    

    </details>

  19. in src/test/compress_tests.cpp:174 in 5500521f8b
     169 | +        // The Unser path should throw an exception when it can't be decompressed. Otherwise,
     170 | +        // it will leave the script empty, which consensus-wise means anyone-can-spend,
     171 | +        // instead of being an unspendable off-curve P2PK script.
     172 | +        DataStream s = DataStream{} << VARINT(compression_id) << std::span{compressed_script};
     173 | +        CScript restored;
     174 | +        BOOST_CHECK_EXCEPTION(s >> Using<ScriptCompression>(restored), std::ios_base::failure, HasReason{"Non-decodable compressed script"});
    


    l0rinc commented at 4:07 AM on September 29, 2026:

    For completeness we could round-trip 2/3 as well

    <details> <summary>Cover off-curve keys in compression classes 2 and 3</summary>

    diff --git a/src/test/compress_tests.cpp b/src/test/compress_tests.cpp
    index 533a2a49de..35594d2480 100644
    --- a/src/test/compress_tests.cpp
    +++ b/src/test/compress_tests.cpp
    @@ -186,6 +186,11 @@ BOOST_AUTO_TEST_CASE(compress_p2pk_scripts_not_on_curve)
     
         // Check off-curve script serialization round-trips properly
         CheckScriptCompressionRoundTrip(script, 68);
    +
    +    // Classes 2/3 preserve off-curve keys verbatim and remain valid stored records
    +    for (const CPubKey& compressed_pubkey : x_not_on_curve.GetCPubKeys()) {
    +        CheckScriptCompressionRoundTrip(CScript() << compressed_pubkey << OP_CHECKSIG, 33);
    +    }
     }
     
     BOOST_AUTO_TEST_CASE(compress_p2pk_wrong_y)
    
    

    </details>

  20. in src/compressor.h:86 in 5500521f8b
      81 | @@ -80,7 +82,9 @@ struct ScriptCompression
      82 |          if (nSize < nSpecialScripts) {
      83 |              CompressedScript vch(GetSpecialScriptSize(nSize), 0x00);
      84 |              s >> std::span{vch};
      85 | -            DecompressScript(script, nSize, vch);
      86 | +            if (!DecompressScript(script, nSize, vch)) {
      87 | +                throw std::ios_base::failure("Non-decodable compressed script");
    


    l0rinc commented at 4:45 AM on September 29, 2026:

    For diagnostics we could include the compression class and a hex dump of the payload as well.

    <details> <summary>Include the compression class and payload in decode errors</summary>

    diff --git a/src/compressor.h b/src/compressor.h
    index d252c0b712..acd25aa2e8 100644
    --- a/src/compressor.h
    +++ b/src/compressor.h
    @@ -6,11 +6,13 @@
     #ifndef BITCOIN_COMPRESSOR_H
     #define BITCOIN_COMPRESSOR_H
     
    +#include <crypto/hex_base.h>
     #include <prevector.h>
     #include <primitives/transaction.h>
     #include <script/script.h>
     #include <serialize.h>
     #include <span.h>
    +#include <tinyformat.h>
     
     #include <ios>
     
    @@ -83,7 +85,7 @@ struct ScriptCompression
                 CompressedScript vch(GetSpecialScriptSize(nSize), 0x00);
                 s >> std::span{vch};
                 if (!DecompressScript(script, nSize, vch)) {
    -                throw std::ios_base::failure("Non-decodable compressed script");
    +                throw std::ios_base::failure(strprintf("Non-decodable compressed script (type %u, data %s)", nSize, HexStr(vch)));
                 }
                 return;
             }
    
    

    </details>


    l0rinc commented at 9:21 PM on September 29, 2026:

    When dumptxoutset creates a rollback snapshot, the copy loop skips coins on read failure. The cursor catches the new decompression exception and returns false, so this PR adds another trigger for that issue. Could we abort the copy on a failed read, either here or in a separate follow-up? Otherwise, dumptxoutset can succeed with a coin missing.

    scantxoutset uses the same cursor reader, but a failed read currently looks like cancellation (success: false with partial results). Could we return the UTXO read error there as well? The existing corruption test can cover both RPCs before triggering the validation abort.

    <details> <summary>Report unreadable coins in rollback exports and UTXO scans</summary>

    diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp
    index ae75072462..27223dda57 100644
    --- a/src/rpc/blockchain.cpp
    +++ b/src/rpc/blockchain.cpp
    @@ -2258,7 +2258,7 @@ bool FindScriptPubKey(std::atomic<int>& scan_progress, const std::atomic<bool>&
         while (cursor->Valid()) {
             COutPoint key;
             Coin coin;
    -        if (!cursor->GetKey(key) || !cursor->GetValue(coin)) return false;
    +        if (!cursor->GetKey(key) || !cursor->GetValue(coin)) throw JSONRPCError(RPC_INTERNAL_ERROR, "Unable to read UTXO set");
             if (++count % 8192 == 0) {
                 interruption_point();
                 if (should_abort) {
    @@ -3304,6 +3304,8 @@ UniValue CreateRolledBackUTXOSnapshot(
                     if (coins_count % 100'000 == 0) {
                         temp_cache.Flush();
                     }
    +            } else {
    +                throw JSONRPCError(RPC_INTERNAL_ERROR, "Unable to read UTXO set");
                 }
                 cursor->Next();
             }
    diff --git a/test/functional/feature_utxo_abort_on_error.py b/test/functional/feature_utxo_abort_on_error.py
    index 5e69333c13..dbfcd0e262 100755
    --- a/test/functional/feature_utxo_abort_on_error.py
    +++ b/test/functional/feature_utxo_abort_on_error.py
    @@ -22,7 +22,7 @@ except ImportError:
     
     from test_framework.blocktools import COINBASE_MATURITY
     from test_framework.test_framework import BitcoinTestFramework, SkipTest
    -from test_framework.util import assert_equal
    +from test_framework.util import assert_equal, assert_raises_rpc_error
     from test_framework.wallet import MiniWallet
     
     
    @@ -87,6 +87,12 @@ class UTXOAbortOnErrorTest(BitcoinTestFramework):
             # touches them. The node starts cleanly regardless of the tampered entry.
             assert_equal(node0.getblockcount(), COINBASE_MATURITY + 1)
     
    +        self.log.info("Rollback export must fail when it cannot read a coin")
    +        assert_raises_rpc_error(-32603, "Unable to read UTXO set", node0.dumptxoutset, "rollback.dat", rollback=COINBASE_MATURITY)
    +
    +        self.log.info("UTXO set scan must fail when it cannot read a coin")
    +        assert_raises_rpc_error(-32603, "Unable to read UTXO set", node0.scantxoutset, "start", ["raw(51)"])
    +
             # Now spend the corrupted UTXO
             self.log.info("node1 broadcasts the spend and mines a block")
             node1.sendrawtransaction(spend_tx_hex)
    
    

    </details>

  21. in src/test/fuzz/script.cpp:123 in 5500521f8b
     123 | +        // Decodable unless it is a pubkey record (type 4 or 5) whose X coordinate is not a valid compressed pubkey.
     124 | +        bool decodable{true};
     125 | +        if (type == 4 || type == 5) {
     126 | +            std::vector<unsigned char> pubkey{static_cast<unsigned char>(type - 2)};
     127 | +            pubkey.insert(pubkey.end(), payload.begin(), payload.end());
     128 | +            decodable = CPubKey{pubkey}.IsFullyValid();
    


    l0rinc commented at 8:54 PM on September 29, 2026:

    nit: I think we can simplify this a bit

    <details> <summary>Use XOnlyPubKey as the decodability oracle</summary>

    diff --git a/src/test/fuzz/script.cpp b/src/test/fuzz/script.cpp
    index 3adf140b78..6cc391c9d6 100644
    --- a/src/test/fuzz/script.cpp
    +++ b/src/test/fuzz/script.cpp
    @@ -117,13 +117,8 @@ FUZZ_TARGET(script, .init = initialize_script)
             std::vector<uint8_t> payload = ConsumeFixedLengthByteVector(fuzzed_data_provider, std::min(payload_size, 32U));
             payload.resize(payload_size);
             const std::vector<std::byte> trailing = ConsumeRandomLengthByteVector<std::byte>(fuzzed_data_provider);
    -        // Decodable unless it is a pubkey record (type 4 or 5) whose X coordinate is not a valid compressed pubkey.
    -        bool decodable{true};
    -        if (type == 4 || type == 5) {
    -            std::vector<unsigned char> pubkey{static_cast<unsigned char>(type - 2)};
    -            pubkey.insert(pubkey.end(), payload.begin(), payload.end());
    -            decodable = CPubKey{pubkey}.IsFullyValid();
    -        }
    +        // Decodable unless it is a pubkey record (type 4 or 5) whose X coordinate is not on the curve.
    +        const bool decodable{(type != 4 && type != 5) || XOnlyPubKey{payload}.IsFullyValid()};
     
             DataStream stream = DataStream{} << VARINT(type) << std::span{payload} << std::span{trailing};
             try {
    
    

    </details>

  22. in src/test/fuzz/script.cpp:98 in 5500521f8b
      98 | -        CompressedScript compressed_script;
      99 | -        compressed_script.assign(bytes.begin(), bytes.end());
     100 | -        // DecompressScript(..., ..., bytes) is not guaranteed to be defined if the bytes vector is too short
     101 | -        if (compressed_script.size() >= 32) {
     102 | +        // Exercises ScriptCompression::Unser path.
     103 | +        // First, this always runs inside a Coin record, so more data follows the script
    


    l0rinc commented at 8:57 PM on September 29, 2026:

    nit: the script is the last field of a Coin, so nothing follows it in a chainstate value. Undo records and snapshot files are where more data follows.

    <details> <summary>Correct where trailing data follows the script</summary>

    diff --git a/src/test/fuzz/script.cpp b/src/test/fuzz/script.cpp
    index 2db84f6ef3..a6929d3a8f 100644
    --- a/src/test/fuzz/script.cpp
    +++ b/src/test/fuzz/script.cpp
    @@ -95,8 +95,7 @@ FUZZ_TARGET(script, .init = initialize_script)
     
         {
             // Exercises ScriptCompression::Unser path.
    -        // First, this always runs inside a Coin record, so more data follows the script
    -        // in the stream and it must not be consumed.
    +        // Undo records and snapshot files store more data after the script, which must not be consumed.
             //
             // A compressed script starts with its type: one of six special encodings (0 to 5) with
             // a fixed-size payload, or a raw script length plus nSpecialScripts. A special script
    
    

    </details>

  23. in src/test/compress_tests.cpp:172 in 5500521f8b
     164 | @@ -161,6 +165,48 @@ BOOST_AUTO_TEST_CASE(compress_p2pk_scripts_not_on_curve)
     165 |          CScript uncompressed_script;
     166 |          bool success = DecompressScript(uncompressed_script, compression_id, compressed_script);
     167 |          BOOST_CHECK_EQUAL(success, false);
     168 | +
     169 | +        // The Unser path should throw an exception when it can't be decompressed. Otherwise,
     170 | +        // it will leave the script empty, which consensus-wise means anyone-can-spend,
     171 | +        // instead of being an unspendable off-curve P2PK script.
     172 | +        DataStream s = DataStream{} << VARINT(compression_id) << std::span{compressed_script};
    


    l0rinc commented at 9:02 PM on September 29, 2026:

    Given the concern about exceptions being caught along the way (hinted at in the PR description and discussed in several related PRs and issues recently), could we add simple higher-level checks that Coin and CTxUndo deserialization propagate this new failure?

    <details> <summary>Check failure propagation through Coin and undo deserialization</summary>

    diff --git a/src/test/coins_tests.cpp b/src/test/coins_tests.cpp
    index d2bd053d55..064e0bddc9 100644
    --- a/src/test/coins_tests.cpp
    +++ b/src/test/coins_tests.cpp
    @@ -543,6 +543,10 @@ BOOST_AUTO_TEST_CASE(ccoins_serialization)
         // scriptPubKey that ends beyond the end of the stream
         BOOST_CHECK_EXCEPTION(SpanReader{"000007"_hex} >> Coin{}, std::ios_base::failure, HasReason{"end of data"});
    
    +    // Coin and undo deserialization must propagate the script decompression failure
    +    BOOST_CHECK_EXCEPTION(SpanReader{"000004ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"_hex} >> Coin{}, std::ios_base::failure, HasReason{"Non-decodable compressed script"});
    +    BOOST_CHECK_EXCEPTION(SpanReader{"01000004ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"_hex} >> CTxUndo{}, std::ios_base::failure, HasReason{"Non-decodable compressed script"});
    +
         // Very large scriptPubKey (3*10^9 bytes) past the end of the stream
         DataStream tmp{};
         uint64_t x = 3000000000ULL;
    

    </details>

  24. in src/test/fuzz/script.cpp:116 in 5500521f8b
     116 | +            // For a script with length > MAX_SCRIPT_SIZE the script is replaced for an OP_RETURN
     117 | +            payload_size = fuzzed_data_provider.ConsumeIntegralInRange<unsigned int>(0, MAX_SCRIPT_SIZE + 16);
     118 | +            type = payload_size + ScriptCompression::nSpecialScripts;
     119 | +        }
     120 | +
     121 | +        const std::vector<uint8_t> payload = ConsumeFixedLengthByteVector(fuzzed_data_provider, payload_size);
    


    l0rinc commented at 9:04 PM on September 29, 2026:

    nit: could we cap raw-payload consumption to leave more fuzz input for later checks? The fuzzer might already handle this, but I would expect it to have an easier time this way...

    <details> <summary>Limit payload consumption while keeping varied bytes</summary>

    diff --git a/src/test/fuzz/script.cpp b/src/test/fuzz/script.cpp
    index 40a74cb008..3adf140b78 100644
    --- a/src/test/fuzz/script.cpp
    +++ b/src/test/fuzz/script.cpp
    @@ -113,7 +113,9 @@ FUZZ_TARGET(script, .init = initialize_script)
                 type = payload_size + ScriptCompression::nSpecialScripts;
             }
     
    -        const std::vector<uint8_t> payload = ConsumeFixedLengthByteVector(fuzzed_data_provider, payload_size);
    +        // Keep varied payload bytes without letting long raw records drain the input
    +        std::vector<uint8_t> payload = ConsumeFixedLengthByteVector(fuzzed_data_provider, std::min(payload_size, 32U));
    +        payload.resize(payload_size);
             const std::vector<std::byte> trailing = ConsumeRandomLengthByteVector<std::byte>(fuzzed_data_provider);
             // Decodable unless it is a pubkey record (type 4 or 5) whose X coordinate is not a valid compressed pubkey.
             bool decodable{true};
    
    

    </details>

  25. l0rinc changes_requested
  26. l0rinc commented at 10:02 PM on September 29, 2026: contributor

    Concept ACK, we have lots of similar findings that we should slowly plug, this seems like a good next step.

    Could we also check how callers handle the new exception, especially where an unreadable coin could be silently skipped? Left a few suggestions for diagnostics and coverage too.

    I’ll follow up separately on the OP_RETURN fallback potentially leaving a spendable script.


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-10-13 05:51 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me