Merge pull request #2565 from Roasbeef/wire-script-arena
What changed, and why it matters
This commit is a large refactoring of how btcd decodes Bitcoin network messages. It replaces a fixed 4 MiB temporary buffer (a 'slab') with a tiered arena allocator and adds checks to avoid reserving huge amounts of memory when a message claims to contain many elements but the data is truncated. The change is primarily defensive: it reduces memory waste and makes malformed messages harder to turn into memory-exhaustion attacks. A small fix in the RPC client also stops a local validation helper from mutating caller-owned transactions.
Review the arena ownership contract carefully: ensure every public decode path copies scripts out of the arena before release, and that failed decodes clear all arena-backed fields. Run the new fuzz/property tests under the race detector and consider adding a corpus test for a maximally large witness item that exhausts the arena to confirm graceful error handling.
Security signals we found
Replaced fixed 4 MiB decode slab with tiered arena to reduce memory amplification and unbounded free-list retention
Added truncated-payload guards (validateElementCount, canPreallocateElements, readBytes) to prevent huge preallocation on malformed length/count prefixes
Cleared TxIn/TxOut on failed MsgTx decode to avoid leaking arena-backed slices to callers
Fixed caller-owned transaction mutation in rpcclient TestMempoolAcceptAsync by decoding into a separate variable
Added property-based, fuzz, concurrency, and decode-budget tests covering arena reuse, aliasing, and truncated inputs
Evidence from the diff
The patch removes the old scriptPool/scriptSlab free list (a 4 MiB per-decode slab with a channel-based free list) and introduces a tiered scriptArena backed by sync.Pool. Scripts are staged in arena chunks during wire decoding, then copied into a single exactly-sized allocation once decoding succeeds. Failed public MsgTx decodes clear TxIn/TxOut before the arena is released. Block decoding reuses one arena across transactions with rewind() between them. Additionally, readBytes() and validateElementCount()/canPreallocateElements() are added so decoders do not preallocate full vectors or payloads when the underlying reader cannot prove enough bytes remain; this bounds memory use for truncated/malicious length prefixes. A one-line fix in rpcclient/rawtransactions.go decodes into a local MsgTx variable so TestMempoolAcceptAsync no longer overwrites the caller’s transaction during local validation.
Changed components
wire package message decoders (MsgTx, MsgBlock, MsgAddr, MsgAddrV2, MsgInv, MsgGetData, MsgNotFound, MsgHeaders, MsgGetBlocks, MsgGetHeaders, MsgMerkleBlock, MsgCFHeaders, MsgCFCheckpt)wire/common.go readBytes/validateElementCount helperswire/arena.go tiered allocatorrpcclient/rawtransactions.go TestMempoolAcceptAsyncInspect captured patch +2654 / −201
### rpcclient/rawtransactions.go
@@ -994,7 +994,8 @@ func (c *Client) TestMempoolAcceptAsync(txns []*wire.MsgTx,
// NOTE: must be performed after buf.Bytes is copied above.
//
// TODO(yy): remove it once the above TODO is addressed.
- if err := tx.Deserialize(buf); err != nil {
+ var decoded wire.MsgTx
+ if err := decoded.Deserialize(buf); err != nil {
err = fmt.Errorf("%w: %v", ErrInvalidParam, err)
return newFutureError(err)
}
### rpcclient/rawtransactions_test.go
@@ -0,0 +1,66 @@
+package rpcclient
+
+import (
+ "bytes"
+ "encoding/hex"
+ "testing"
+
+ "github.com/btcsuite/btcd/btcjson"
+ "github.com/btcsuite/btcd/wire/v2"
+ "github.com/stretchr/testify/require"
+)
+
+// TestMempoolAcceptPreservesTransactions checks that local validation leaves
+// caller-owned transactions unchanged on both success and failure.
+func TestMempoolAcceptPreservesTransactions(t *testing.T) {
+ for _, valid := range []bool{false, true} {
+ name := "invalid"
+ if valid {
+ name = "valid"
+ }
+ t.Run(name, func(t *testing.T) {
+ client := &Client{
+ config: &ConnConfig{HTTPPostMode: true},
+ backendVersion: BitcoindPost25,
+ sendPostChan: make(chan *jsonRequest, 1),
+ shutdown: make(chan struct{}),
+ }
+ tx := wire.NewMsgTx(2)
+ if valid {
+ tx.AddTxIn(wire.NewTxIn(
+ &wire.OutPoint{}, []byte{0x01},
+ wire.TxWitness{{0x02, 0x03}},
+ ))
+ }
+ tx.AddTxOut(wire.NewTxOut(1, []byte{0x04, 0x05}))
+ tx.LockTime = 123
+ before := tx.Copy()
+ inputs := tx.TxIn
+ output := tx.TxOut[0]
+ var serialized bytes.Buffer
+ require.NoError(t, tx.Serialize(&serialized))
+
+ future := client.TestMempoolAcceptAsync([]*wire.MsgTx{tx}, 0.1)
+ require.Equal(t, before, tx)
+ require.Same(t, output, tx.TxOut[0])
+ if !valid {
+ require.Empty(t, client.sendPostChan)
+ require.Len(t, future, 1)
+ _, err := future.Receive()
+ require.ErrorIs(t, err, ErrInvalidParam)
+ return
+ }
+
+ require.Same(t, inputs[0], tx.TxIn[0])
+ require.Len(t, client.sendPostChan, 1)
+ request := <-client.sendPostChan
+ require.Equal(t, "testmempoolaccept", request.method)
+ command := request.cmd.(*btcjson.TestMempoolAcceptCmd)
+ require.Equal(t, []string{hex.EncodeToString(serialized.Bytes())}, command.RawTxns)
+ require.Equal(t, btcjson.BTCPerkvB(0.1), command.MaxFeeRate)
+ request.responseChan <- &Response{result: []byte("[]")}
+ _, err := future.Receive()
+ require.NoError(t, err)
+ })
+ }
+}
### wire/arena.go
@@ -0,0 +1,370 @@
+// Copyright (c) 2026 The btcsuite developers
+// Use of this source code is governed by an ISC
+// license that can be found in the LICENSE file.
+
+package wire
+
+import (
+ "errors"
+ "sync"
+)
+
+// This file implements a small bump allocator ("script arena") used to stage
+// variable-length scripts and witness items while a transaction is being
+// decoded from the wire. Once a transaction is fully decoded, all of its
+// scripts are copied into a single exactly-sized allocation before the arena
+// is reused. A successful decode therefore leaves no arena-backed scripts
+// in the transaction. A failed public transaction decode clears its inputs
+// and outputs before releasing the arena, while block decoding discards the
+// failed transaction. This ownership contract makes arena reuse safe without
+// reference counting.
+//
+// The arena replaces the previous fixed 4 MiB script slab. The slab approach
+// suffered from memory amplification: decoding even a tiny transaction pinned
+// a full 4 MiB buffer for the duration of the decode, a pool miss allocated
+// and zeroed 4 MiB, and the channel-based free list could retain hundreds of
+// megabytes indefinitely since channels are invisible to the garbage
+// collector's pressure heuristics.
+//
+// The arena instead draws fixed-size chunks from a ladder of size classes
+// backed by sync.Pool and bump-allocates scripts out of them. A decode only
+// touches memory proportional to the scripts it actually contains: typical
+// transactions are served entirely by a single 16 KiB chunk while full blocks
+// walk up to the larger classes. Because the chunk pools are sync.Pools,
+// idle chunks are released back to the runtime across garbage collection
+// cycles instead of being pinned forever.
+
+const (
+ // scriptArenaMaxAlloc is the maximum total number of bytes that may be
+ // bump-allocated from a script arena between rewinds. Since the arena
+ // is rewound for each transaction, this bounds the staged script data
+ // of a single transaction. It intentionally matches the size of the
+ // script slab it replaced in order to preserve the existing decode
+ // limits: no valid transaction can contain more script data than the
+ // maximum block payload, so anything beyond this is malformed.
+ scriptArenaMaxAlloc = 1 << 22
+
+ // maxScriptChunkSize is the size of the largest chunk class. It
+ // must be at least scriptArenaMaxAlloc so that any single allocation
+ // passing the arena capacity check can be satisfied by one chunk;
+ // the compile-time assertion below enforces the coupling.
+ maxScriptChunkSize = 1 << 22
+)
+
+// scriptChunkClasses defines the ladder of chunk sizes that back script
+// arenas. The classes were chosen so that the overwhelmingly common cases
+// are served by the first chunk borrowed:
+//
+// - 16 KiB covers virtually all standalone transactions seen on the
+// network, which typically carry well under 1 KiB of script data.
+// - 128 KiB covers large non-standard transactions (the standardness
+// limit for a transaction is 100 KB).
+// - 1 MiB covers transactions with unusually large aggregate script data.
+// - 4 MiB covers the worst case: total script data in a message is
+// bounded by scriptArenaMaxAlloc, and a single witness item is bounded
+// by maxWitnessItemSize, so the largest class can always satisfy any
+// single allocation.
+var scriptChunkClasses = [...]int{
+ 16 << 10,
+ 128 << 10,
+ 1 << 20,
+ maxScriptChunkSize,
+}
+
+// Compile-time assertion that the largest chunk class can satisfy any single
+// allocation that passes the arena capacity check. grow relies on this: if
+// scriptArenaMaxAlloc were raised past maxScriptChunkSize, allocSlow could
+// select a chunk smaller than the requested allocation and panic slicing it.
+var _ [maxScriptChunkSize - scriptArenaMaxAlloc]byte
+
+// scriptChunkFixedCaps is the number of chunks per size class that are
+// retained in a small bounded free list rather than being subject to GC
+// reclamation. The large classes are the expensive ones to re-create (a
+// pool miss zeroes the whole chunk), and only a handful are ever live at
+// once since block decode concurrency is bounded, so pinning a few of them
+// buys stable block decode performance for at most 16*1 MiB + 8*4 MiB =
+// 48 MiB, and only once block traffic has actually warmed them. The 1 MiB
+// class gets the deepest list to absorb concurrent decodes of transactions
+// with large aggregate script data, while the 4 MiB class only serves the
+// rare transaction that stages more than 1 MiB. The small classes cost well
+// under a microsecond to re-create, so they are left entirely to the
+// GC-cooperating sync.Pool.
+var scriptChunkFixedCaps = [len(scriptChunkClasses)]int{0, 0, 16, 8}
+
+// chunkClassPool hands out chunks of a single size class. It layers a
+// small bounded free list (fixed) in front of a sync.Pool: the fixed list
+// gives deterministic reuse for the handful of chunks that stay hot, while
+// the sync.Pool absorbs bursts beyond it and lets the garbage collector
+// reclaim that overflow when it goes idle. The previous design was a single
+// channel free list holding up to 125 four-MiB slabs (~524 MB) that the GC
+// could never reclaim.
+type chunkClassPool struct {
+ fixed chan *[]byte
+ pool sync.Pool
+}
+
+// get returns a chunk for this class, preferring the bounded free list.
+func (p *chunkClassPool) get() *[]byte {
+ select {
+ case chunk := <-p.fixed:
+ return chunk
+ default:
+ }
+ return p.pool.Get().(*[]byte)
+}
+
+// put returns a chunk to this class, refilling the bounded free list first
+// and spilling the rest into the sync.Pool.
+func (p *chunkClassPool) put(chunk *[]byte) {
+ select {
+ case p.fixed <- chunk:
+ default:
+ p.pool.Put(chunk)
+ }
+}
+
+// scriptChunkPools holds one pool per chunk size class.
+var scriptChunkPools = func() [len(scriptChunkClasses)]*chunkClassPool {
+ var pools [len(scriptChunkClasses)]*chunkClassPool
+ for i := range scriptChunkClasses {
+ size := scriptChunkClasses[i]
+ pools[i] = &chunkClassPool{
+ fixed: make(chan *[]byte, scriptChunkFixedCaps[i]),
+ pool: sync.Pool{
+ New: func() interface{} {
+ b := make([]byte, size)
+ return &b
+ },
+ },
+ }
+ }
+ return pools
+}()
+
+// putScriptChunk returns a chunk to the pool for its size class. Chunks
+// are only ever created by the class pools, so their lengths always match a
+// class size exactly. A chunk whose size matches no class can only mean
+// internal corruption, and reusing it would hand some future allocation a
+// buffer too small for the capacity it was granted, so a slice bounds check
+// would panic deep inside a decode. Failing loudly here instead pinpoints
+// the corruption at the source.
+func putScriptChunk(chunk *[]byte) {
+ for i, size := range scriptChunkClasses {
+ if len(*chunk) == size {
+ scriptChunkPools[i].put(chunk)
+ return
+ }
+ }
+
+ panic("putScriptChunk: chunk size matches no class")
+}
+
+// errScriptArenaFull is returned by alloc when satisfying the request would
+// push the total bytes allocated since the last rewind beyond
+// scriptArenaMaxAlloc.
+var errScriptArenaFull = errors.New("script arena capacity exceeded")
+
+// scriptArena is a bump allocator for transient script buffers used during
+// transaction decoding. Allocations are carved out of fixed-size chunks
+// drawn from the tiered chunk pools, and the entire arena is freed at once
+// via release (or recycled via rewind), so individual allocations carry no
+// bookkeeping at all.
+//
+// The zero value is not usable; arenas must be obtained via
+// borrowScriptArena and returned via release. Arenas are not safe for
+// concurrent use, mirroring the decode paths they serve.
+type scriptArena struct {
+ // chunks holds every chunk currently borrowed from the class pools,
+ // in the order they were acquired. The last entry backs cur.
+ chunks []*[]byte
+
+ // cur is the chunk allocations are currently served from and off is
+ // the bump offset within it.
+ cur []byte
+ off int
+
+ // used tracks the total bytes handed out since the last rewind. It
+ // is capped at scriptArenaMaxAlloc to preserve the per-transaction
+ // decode limit previously enforced by the fixed slab size.
+ used int
+
+ // dead marks an arena that has been released. A dead arena rejects all
+ // allocations and ignores rewinds until the arena is borrowed again.
+ // Arenas have a single lexical owner and references must not be retained
+ // after release.
+ dead bool
+}
+
+// scriptArenaPool recycles the arena structs themselves so that decoding a
+// message does not allocate any bookkeeping state in the steady state.
+var scriptArenaPool = sync.Pool{
+ New: func() interface{} {
+ return new(scriptArena)
+ },
+}
+
+// borrowScriptArena returns an arena ready to stage script data. Chunks
+// are borrowed lazily, so an arena that never allocates never touches the
+// chunk pools. The returned arena must be handed back via release.
+//
+// Forgetting to call release is safe in the memory sense: the chunks are
+// ordinary garbage collected slices, so an abandoned arena is simply
+// reclaimed by the GC and only the pooling benefit is lost.
+func borrowScriptArena() *scriptArena {
+ a := scriptArenaPool.Get().(*scriptArena)
+ a.used = 0
+ a.dead = false
+ return a
+}
+
+// remaining returns the number of bytes that may still be allocated before
+// the arena reaches its total capacity.
+func (a *scriptArena) remaining() int {
+ return scriptArenaMaxAlloc - a.used
+}
+
+// alloc returns a slice of exactly n bytes carved from the arena. The
+// returned slice has its capacity clamped to its length so that appends by
+// callers can never bleed into neighboring allocations. The contents are
+// not zeroed; callers are expected to fully overwrite the buffer (the decode
+// path fills every allocation with io.ReadFull).
+//
+// alloc fails with errScriptArenaFull once the total bytes handed out since
+// the last rewind would exceed scriptArenaMaxAlloc, and for any negative
+// size. The decode path never passes a negative size, but rejecting one
+// here keeps alloc total on any input rather than panicking on the slice
+// bounds below, which the remaining-budget check alone does not catch.
+func (a *scriptArena) alloc(n int) ([]byte, error) {
+ if n < 0 || n > a.remaining() {
+ return nil, errScriptArenaFull
+ }
+ if n > len(a.cur)-a.off {
+ return a.allocSlow(n)
+ }
+
+ end := a.off + n
+ s := a.cur[a.off:end:end]
+ a.off = end
+ a.used += n
+
+ return s, nil
+}
+
+// allocSlow services an allocation that does not fit in the current chunk
+// by growing first. It is kept separate so the common in-chunk alloc path
+// stays small; alloc itself still lands just past the compiler's inlining
+// budget due to the call here, which is left as a future optimization.
+func (a *scriptArena) allocSlow(n int) ([]byte, error) {
+ a.grow(n)
+
+ s := a.cur[:n:n]
+ a.off = n
+ a.used += n
+
+ return s, nil
+}
+
+// grow borrows a new chunk large enough to hold n bytes and makes it the
+// current chunk. The chunk comes from the smallest size class that
+// holds both n and one more byte than the current chunk, so consecutive
+// growths walk up the ladder and converge to the largest class in a
+// constant number of steps without any per-arena class bookkeeping. Any
+// request is satisfiable because callers bound n by the arena capacity,
+// which equals the largest class size.
+func (a *scriptArena) grow(n int) {
+ sizeClass := 0
+ for scriptChunkClasses[sizeClass] < max(n, len(a.cur)+1) {
+ sizeClass++
+ }
+
+ chunk := scriptChunkPools[sizeClass].get()
+ a.chunks = append(a.chunks, chunk)
+ a.cur = *chunk
+ a.off = 0
+}
+
+// rewind resets the arena so previously allocated memory is reused from the
+// start, invalidating every slice handed out since the last rewind. It is
+// called between transactions while decoding a block: each transaction copies
+// its scripts into a single exactly-sized allocation before decoding of the
+// next one begins, so the staged data is already dead by the time rewind
+// runs.
+//
+// To keep the reused footprint minimal, only the largest chunk is retained;
+// smaller chunks acquired while the arena was warming up are returned to
+// their pools.
+func (a *scriptArena) rewind() {
+ // A released arena stays dead: rewinding must not resurrect its
+ // capacity, otherwise a stale reference could allocate out of chunks
+ // that have already been handed to another decode.
+ if a.dead {
+ return
+ }
+
+ a.used = 0
+ a.off = 0
+
+ // The common case by far is a single chunk (a transaction that fit
+ // its starting class), where cur already points at it and resetting
+ // the offsets above is all that's needed.
+ if len(a.chunks) <= 1 {
+ return
+ }
+
+ a.rewindSlow()
+}
+
+// rewindSlow handles the multi-chunk case of rewind, kept out of line so
+// the hot single-chunk rewind path stays small enough to inline.
+func (a *scriptArena) rewindSlow() {
+ // Find the largest chunk, return the rest, and continue serving
+ // allocations from it.
+ largest := 0
+ for i := 1; i < len(a.chunks); i++ {
+ if len(*a.chunks[i]) > len(*a.chunks[largest]) {
+ largest = i
+ }
+ }
+ keep := a.chunks[largest]
+ for i, chunk := range a.chunks {
+ if i != largest {
+ putScriptChunk(chunk)
+ }
+ a.chunks[i] = nil
+ }
+
+ a.chunks = append(a.chunks[:0], keep)
+ a.cur = *keep
+ // Nothing else to do: grow derives the next class from the retained
+ // chunk's size, so there is no class cursor to reposition.
+}
+
+// release returns every chunk to its class pool and recycles the arena
+// struct itself. All slices previously returned by alloc are invalidated.
+//
+// release is idempotent until the arena is borrowed again: a second call on
+// an already released arena is a no-op rather than double-inserting it into
+// the pool. A released arena also rejects further allocations until it is
+// borrowed again. Callers must not retain an arena reference after release.
+func (a *scriptArena) release() {
+ if a.dead {
+ return
+ }
+
+ for i, chunk := range a.chunks {
+ putScriptChunk(chunk)
+ a.chunks[i] = nil
+ }
+ a.chunks = a.chunks[:0]
+ a.cur = nil
+ a.off = 0
+
+ // Poison the capacity so any alloc through a stale reference fails
+ // with errScriptArenaFull rather than succeeding against recycled
+ // memory. One past the limit makes remaining negative, so even a
+ // zero-length alloc is rejected.
+ a.used = scriptArenaMaxAlloc + 1
+ a.dead = true
+
+ scriptArenaPool.Put(a)
+}
### wire/arena_rapid_test.go
@@ -0,0 +1,310 @@
+// Copyright (c) 2026 The btcsuite developers
+// Use of this source code is governed by an ISC
+// license that can be found in the LICENSE file.
+
+package wire
+
+import (
+ "bytes"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "pgregory.net/rapid"
+)
+
+// poisonScriptArena scribbles over the exact chunks used by a decode. A
+// decoded transaction that still aliases its staging arena will be corrupted.
+func poisonScriptArena(ar *scriptArena) {
+ for _, chunk := range ar.chunks {
+ for i := range *chunk {
+ (*chunk)[i] = 0xaa
+ }
+ }
+}
+
+// genScript returns a rapid generator for a non-nil script of up to maxLen
+// bytes. Zero-length scripts are always non-nil so that decoded messages,
+// which never produce nil scripts, compare equal structurally.
+func genScript(rt *rapid.T, label string, maxLen int) []byte {
+ n := rapid.IntRange(0, maxLen).Draw(rt, label+"Len")
+ s := make([]byte, n)
+ for i := 0; i < n; i++ {
+ s[i] = byte(rapid.IntRange(0, 255).Draw(rt, label+"Byte"))
+ }
+ return s
+}
+
+// genMsgTx generates a structurally valid random transaction. Script and
+// witness sizes are biased small like real traffic but occasionally exceed
+// the 16 KiB starting chunk class so the arena growth path is exercised.
+func genMsgTx(rt *rapid.T) *MsgTx {
+ tx := NewMsgTx(int32(rapid.IntRange(0, 2).Draw(rt, "version")))
+
+ // Most generated scripts are small, but roughly one in ten draws
+ // from a range that overflows the first chunk class.
+ scriptMax := 512
+ if rapid.IntRange(0, 9).Draw(rt, "bigScripts") == 0 {
+ scriptMax = 3 * scriptChunkClasses[0]
+ }
+
+ numIn := rapid.IntRange(1, 6).Draw(rt, "numIn")
+ hasWitness := rapid.Bool().Draw(rt, "hasWitness")
+ for i := 0; i < numIn; i++ {
+ ti := &TxIn{
+ PreviousOutPoint: OutPoint{
+ Index: uint32(rapid.IntRange(0, 1<<30).Draw(
+ rt, "prevIndex",
+ )),
+ },
+ SignatureScript: genScript(rt, "sigScript", scriptMax),
+ Sequence: uint32(
+ rapid.IntRange(0, 1<<30).Draw(rt, "sequence"),
+ ),
+ }
+ for j := range ti.PreviousOutPoint.Hash {
+ ti.PreviousOutPoint.Hash[j] = byte(
+ rapid.IntRange(0, 255).Draw(rt, "prevHash"),
+ )
+ }
+
+ // When the transaction is witnessy every input carries a
+ // (possibly empty) non-nil witness stack, matching what the
+ // decoder produces.
+ if hasWitness {
+ numItems := rapid.IntRange(0, 3).Draw(rt, "numWitness")
+ ti.Witness = make(TxWitness, numItems)
+ for j := 0; j < numItems; j++ {
+ ti.Witness[j] = genScript(
+ rt, "witnessItem", scriptMax,
+ )
+ }
+ }
+
+ tx.AddTxIn(ti)
+ }
+
+ // A witness marker with zero total witness items is rejected by the
+ // decoder (and never produced by the encoder), so force at least one
+ // item when the transaction claims to be witnessy.
+ if hasWitness && !tx.HasWitness() {
+ tx.TxIn[0].Witness = TxWitness{
+ genScript(rt, "forcedWitness", scriptMax),
+ }
+ }
+
+ numOut := rapid.IntRange(0, 6).Draw(rt, "numOut")
+ for i := 0; i < numOut; i++ {
+ tx.AddTxOut(&TxOut{
+ Value: rapid.Int64Range(0, 1<<40).Draw(rt, "value"),
+ PkScript: genScript(rt, "pkScript", scriptMax),
+ })
+ }
+
+ tx.LockTime = uint32(rapid.IntRange(0, 1<<30).Draw(rt, "lockTime"))
+
+ return tx
+}
+
+// TestScriptArenaPropertyTxRoundTrip checks two properties over random
+// transactions: serialize/deserialize is the identity, and the decoded
+// transaction owns all of its memory after its exact staging chunks are
+// overwritten.
+func TestScriptArenaPropertyTxRoundTrip(t *testing.T) {
+ rapid.Check(t, func(rt *rapid.T) {
+ tx := genMsgTx(rt)
+
+ var wireBuf bytes.Buffer
+ require.NoError(rt, tx.Serialize(&wireBuf))
+
+ ar := borrowScriptArena()
+ defer ar.release()
+
+ buf := binarySerializer.Borrow()
+ defer binarySerializer.Return(buf)
+
+ var decoded MsgTx
+ err := decoded.btcDecode(
+ bytes.NewReader(wireBuf.Bytes()), 0, WitnessEncoding, buf, ar,
+ )
+ require.NoError(rt, err)
+
+ var reserialized bytes.Buffer
+ require.NoError(rt, decoded.Serialize(&reserialized))
+ require.Equal(rt, wireBuf.Bytes(), reserialized.Bytes())
+
+ // Overwrite the exact chunks used by this decode rather than
+ // relying on a later pool lookup to return the same chunks.
+ poisonScriptArena(ar)
+
+ var afterPoison bytes.Buffer
+ require.NoError(rt, decoded.Serialize(&afterPoison))
+ require.Equal(rt, wireBuf.Bytes(), afterPoison.Bytes())
+ })
+}
+
+// TestScriptArenaPropertyBlockRoundTrip checks round-trip stability for full
+// blocks, including the rewinds performed while transactions share an arena.
+func TestScriptArenaPropertyBlockRoundTrip(t *testing.T) {
+ rapid.Check(t, func(rt *rapid.T) {
+ block := &MsgBlock{Header: blockOne.Header}
+ numTxs := rapid.IntRange(1, 8).Draw(rt, "numTxs")
+ for i := 0; i < numTxs; i++ {
+ block.AddTransaction(genMsgTx(rt))
+ }
+
+ var wireBuf bytes.Buffer
+ require.NoError(rt, block.Serialize(&wireBuf))
+
+ var decoded MsgBlock
+ err := decoded.Deserialize(bytes.NewReader(wireBuf.Bytes()))
+ require.NoError(rt, err)
+
+ var reserialized bytes.Buffer
+ require.NoError(rt, decoded.Serialize(&reserialized))
+ require.Equal(rt, wireBuf.Bytes(), reserialized.Bytes())
+
+ // Decode the serialized block again to exercise arena reuse, then
+ // verify the first decoded block remains stable.
+ var again MsgBlock
+ err = again.Deserialize(bytes.NewReader(reserialized.Bytes()))
+ require.NoError(rt, err)
+
+ var afterReuse bytes.Buffer
+ require.NoError(rt, decoded.Serialize(&afterReuse))
+ require.Equal(rt, wireBuf.Bytes(), afterReuse.Bytes())
+ })
+}
+
+// TestScriptArenaPropertyAllocator drives the raw allocator with random
+// alloc/rewind sequences against a simple model, checking capacity
+// accounting and that live allocations never alias one another.
+func TestScriptArenaPropertyAllocator(t *testing.T) {
+ rapid.Check(t, func(rt *rapid.T) {
+ ar := borrowScriptArena()
+ defer ar.release()
+
+ type allocation struct {
+ s []byte
+ marker byte
+ }
+ var live []allocation
+ modelUsed := 0
+
+ verifyLive := func() {
+ for _, a := range live {
+ count := bytes.Count(a.s, []byte{a.marker})
+ require.Equal(rt, len(a.s), count)
+ }
+ }
+
+ steps := rapid.IntRange(1, 60).Draw(rt, "steps")
+ for i := 0; i < steps; i++ {
+ if rapid.IntRange(0, 9).Draw(rt, "op") == 0 {
+ verifyLive()
+ ar.rewind()
+ live = live[:0]
+ modelUsed = 0
+ require.LessOrEqual(rt, len(ar.chunks), 1)
+ continue
+ }
+
+ n := rapid.IntRange(0, 300_000).Draw(rt, "n")
+ s, err := ar.alloc(n)
+
+ // The model predicts exactly when allocation fails.
+ if n > scriptArenaMaxAlloc-modelUsed {
+ require.ErrorIs(
+ rt, err, errScriptArenaFull,
+ )
+ continue
+ }
+ require.NoError(rt, err)
+ require.Len(rt, s, n)
+ require.Equal(rt, n, cap(s))
+ modelUsed += n
+ require.Equal(
+ rt, scriptArenaMaxAlloc-modelUsed,
+ ar.remaining(),
+ )
+
+ marker := byte(len(live) % 251)
+ for j := range s {
+ s[j] = marker
+ }
+ live = append(live, allocation{s: s, marker: marker})
+ }
+ verifyLive()
+ })
+}
+
+// TestReadTxOutOwnedScript ensures the script returned by the exported
+// ReadTxOut has an exact-sized backing allocation owned by the output.
+func TestReadTxOutOwnedScript(t *testing.T) {
+ const sizeClass = 0
+ size := scriptChunkClasses[sizeClass]
+ testPool := &chunkClassPool{
+ fixed: make(chan *[]byte, 1),
+ pool: sync.Pool{
+ New: func() interface{} {
+ chunk := make([]byte, size)
+ return &chunk
+ },
+ },
+ }
+
+ origPool := scriptChunkPools[sizeClass]
+ scriptChunkPools[sizeClass] = testPool
+ t.Cleanup(func() {
+ scriptChunkPools[sizeClass] = origPool
+ })
+
+ orig := blockOne.Transactions[0].TxOut[0]
+ var buf bytes.Buffer
+ require.NoError(t, WriteTxOut(&buf, 0, 0, orig))
+
+ var txOut TxOut
+ require.NoError(t, ReadTxOut(bytes.NewReader(buf.Bytes()), 0, 0, &txOut))
+ require.Equal(t, orig.PkScript, txOut.PkScript)
+ require.Equal(t, len(txOut.PkScript), cap(txOut.PkScript))
+
+ // ReadTxOut releases its staging arena before returning. Pull the exact
+ // chunk back out of the isolated pool and overwrite it to ensure the
+ // returned script does not alias staging memory.
+ chunk := testPool.get()
+ for i := range *chunk {
+ (*chunk)[i] = 0xaa
+ }
+ require.Equal(t, orig.PkScript, txOut.PkScript)
+}
+
+// TestScriptArenaReleaseSafety exercises the misuse guards within a single
+// ownership interval: double release is a no-op, and a released arena refuses
+// to allocate even after a rewind.
+func TestScriptArenaReleaseSafety(t *testing.T) {
+ ar := borrowScriptArena()
+ _, err := ar.alloc(128)
+ require.NoError(t, err)
+
+ ar.release()
+
+ // Allocating through a stale reference must fail loudly.
+ _, err = ar.alloc(1)
+ require.ErrorIs(t, err, errScriptArenaFull)
+
+ // A rewind must not resurrect a released arena.
+ ar.rewind()
+ _, err = ar.alloc(1)
+ require.ErrorIs(t, err, errScriptArenaFull)
+
+ // Double release must be a no-op rather than double-inserting the
+ // arena into the pool.
+ ar.release()
+
+ // A fresh borrow resets the poisoned state.
+ fresh := borrowScriptArena()
+ defer fresh.release()
+ s, err := fresh.alloc(32)
+ require.NoError(t, err)
+ require.Len(t, s, 32)
+}
### wire/arena_test.go
@@ -0,0 +1,344 @@
+// Copyright (c) 2026 The btcsuite developers
+// Use of this source code is governed by an ISC
+// license that can be found in the LICENSE file.
+
+package wire
+
+import (
+ "bytes"
+ "fmt"
+ "math/rand"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestScriptArenaAlloc ensures basic allocation invariants: exact lengths,
+// capacity clamped to length, and no overlap between consecutive
+// allocations.
+func TestScriptArenaAlloc(t *testing.T) {
+ ar := borrowScriptArena()
+ defer ar.release()
+
+ sizes := []int{0, 1, 25, 512, 1000, 33}
+ allocs := make([][]byte, 0, len(sizes))
+ for _, size := range sizes {
+ s, err := ar.alloc(size)
+ require.NoError(t, err)
+ require.Len(t, s, size)
+ require.Equal(t, size, cap(s))
+
+ // Fill the allocation with a marker so overlap with any
+ // other allocation is detectable below.
+ for i := range s {
+ s[i] = byte(len(allocs))
+ }
+ allocs = append(allocs, s)
+ }
+
+ // Every allocation must still hold its own marker; a bump allocator
+ // bug that handed out overlapping memory would have clobbered one of
+ // the earlier allocations.
+ for marker, s := range allocs {
+ require.Equal(
+ t, len(s), bytes.Count(s, []byte{byte(marker)}),
+ )
+ }
+}
+
+// TestScriptArenaGrowth ensures the arena walks up the chunk size class
+// ladder as demand grows and that a single allocation larger than the next
+// class skips ahead to a class that fits it.
+func TestScriptArenaGrowth(t *testing.T) {
+ ar := borrowScriptArena()
+ defer ar.release()
+
+ // The first allocation draws the starting class.
+ _, err := ar.alloc(100)
+ require.NoError(t, err)
+ require.Len(t, ar.chunks, 1)
+ require.Equal(t, scriptChunkClasses[0], len(*ar.chunks[0]))
+
+ // Exhausting the first chunk must borrow the next class up.
+ _, err = ar.alloc(scriptChunkClasses[0])
+ require.NoError(t, err)
+ require.Len(t, ar.chunks, 2)
+ require.Equal(t, scriptChunkClasses[1], len(*ar.chunks[1]))
+
+ // An allocation bigger than the next class in the ladder must skip
+ // ahead to one that fits it in a single chunk.
+ _, err = ar.alloc(scriptChunkClasses[2] + 1)
+ require.NoError(t, err)
+ require.Len(t, ar.chunks, 3)
+ require.Equal(
+ t, scriptChunkClasses[len(scriptChunkClasses)-1],
+ len(*ar.chunks[2]),
+ )
+}
+
+// TestScriptArenaAllocNegativeSize ensures a negative allocation size is
+// rejected with errScriptArenaFull. The decode path never passes one, but
+// a caller that did would otherwise panic on slice bounds since the
+// capacity check only compares against the remaining budget.
+func TestScriptArenaAllocNegativeSize(t *testing.T) {
+ ar := borrowScriptArena()
+ defer ar.release()
+
+ for _, size := range []int{-1, -58, -(8 << 20)} {
+ _, err := ar.alloc(size)
+ require.ErrorIs(t, err, errScriptArenaFull, "size %d", size)
+ }
+
+ // Rejected allocations must not consume any of the budget.
+ require.Equal(t, scriptArenaMaxAlloc, ar.remaining())
+}
+
+// TestScriptArenaCapacity ensures the arena enforces the per-transaction
+// staging limit that the fixed slab previously provided.
+func TestScriptArenaCapacity(t *testing.T) {
+ ar := borrowScriptArena()
+ defer ar.release()
+
+ // Fill the arena right up to its capacity.
+ _, err := ar.alloc(scriptArenaMaxAlloc - 1)
+ require.NoError(t, err)
+ require.Equal(t, 1, ar.remaining())
+
+ _, err = ar.alloc(1)
+ require.NoError(t, err)
+ require.Equal(t, 0, ar.remaining())
+
+ // The next allocation must fail, and a rewind must restore the full
+ // capacity.
+ _, err = ar.alloc(1)
+ require.ErrorIs(t, err, errScriptArenaFull)
+
+ ar.rewind()
+ require.Equal(t, scriptArenaMaxAlloc, ar.remaining())
+
+ _, err = ar.alloc(1)
+ require.NoError(t, err)
+}
+
+// TestScriptArenaRewind ensures rewinding recycles the memory of the largest
+// chunk in place and returns the smaller warm-up chunks to their pools.
+func TestScriptArenaRewind(t *testing.T) {
+ ar := borrowScriptArena()
+ defer ar.release()
+
+ // Grow through two classes.
+ _, err := ar.alloc(scriptChunkClasses[0])
+ require.NoError(t, err)
+ _, err = ar.alloc(scriptChunkClasses[0])
+ require.NoError(t, err)
+ require.Len(t, ar.chunks, 2)
+ largest := *ar.chunks[1]
+
+ ar.rewind()
+
+ // Only the largest chunk survives the rewind and subsequent
+ // allocations are served from its start.
+ require.Len(t, ar.chunks, 1)
+ s, err := ar.alloc(8)
+ require.NoError(t, err)
+
+ // The allocation must be served from the retained chunk itself, not
+ // from a copy that merely holds equal bytes.
+ require.Same(t, &largest[0], &s[0])
+}
+
+// TestBlockArenaTransactionReuse ensures block staging depends on the largest
+// transaction rather than the aggregate scripts across the block.
+func TestBlockArenaTransactionReuse(t *testing.T) {
+ for _, locations := range []bool{false, true} {
+ name := "deserialize"
+ if locations {
+ name = "transaction_locations"
+ }
+
+ t.Run(name, func(t *testing.T) {
+ original := scriptChunkPools
+ t.Cleanup(func() {
+ scriptChunkPools = original
+ })
+
+ var allocated [len(scriptChunkClasses)]int
+ for i, size := range scriptChunkClasses {
+ scriptChunkPools[i] = &chunkClassPool{
+ fixed: make(
+ chan *[]byte, scriptChunkFixedCaps[i],
+ ),
+ pool: sync.Pool{New: func() interface{} {
+ allocated[i]++
+ chunk := make([]byte, size)
+ return &chunk
+ }},
+ }
+ }
+
+ block := MsgBlock{Header: blockOne.Header}
+ for i := 0; i < 1000; i++ {
+ block.AddTransaction(blockOne.Transactions[0])
+ }
+
+ var encoded bytes.Buffer
+ require.NoError(t, block.Serialize(&encoded))
+
+ var decoded MsgBlock
+ if locations {
+ locs, err := decoded.DeserializeTxLoc(
+ bytes.NewBuffer(encoded.Bytes()),
+ )
+ require.NoError(t, err)
+ require.Len(t, locs, len(block.Transactions))
+ } else {
+ require.NoError(t, decoded.Deserialize(
+ bytes.NewReader(encoded.Bytes()),
+ ))
+ }
+
+ var roundTrip bytes.Buffer
+ require.NoError(t, decoded.Serialize(&roundTrip))
+ require.Equal(t, encoded.Bytes(), roundTrip.Bytes())
+
+ // The whole block must be staged by a single smallest-class
+ // chunk: one borrow serves every transaction.
+ for i, count := range allocated {
+ want := 0
+ if i == 0 {
+ want = 1
+ }
+ require.Equal(t, want, count, "class %d", i)
+ }
+ })
+ }
+}
+
+// TestScriptArenaDecodeGrowth decodes a transaction whose script data
+// overflows the starting chunk class for standalone transactions, ensuring
+// the decode path grows the arena transparently.
+func TestScriptArenaDecodeGrowth(t *testing.T) {
+ // Build a transaction with a signature script comfortably larger
+ // than the 16 KiB starting chunk.
+ bigScript := make([]byte, 3*scriptChunkClasses[0])
+ rng := rand.New(rand.NewSource(1337))
+ rng.Read(bigScript)
+
+ tx := NewMsgTx(1)
+ tx.AddTxIn(&TxIn{
+ PreviousOutPoint: OutPoint{Index: 0xffffffff},
+ SignatureScript: bigScript,
+ Sequence: 0xffffffff,
+ })
+ tx.AddTxOut(NewTxOut(0, []byte{0x51}))
+
+ var buf bytes.Buffer
+ require.NoError(t, tx.Serialize(&buf))
+
+ var decoded MsgTx
+ require.NoError(t, decoded.Deserialize(bytes.NewReader(buf.Bytes())))
+ require.Equal(t, bigScript, decoded.TxIn[0].SignatureScript)
+}
+
+// TestScriptArenaConcurrentDecode exercises the chunk pools from many
+// goroutines at once to give the race detector a chance to catch unsound
+// sharing between arenas.
+func TestScriptArenaConcurrentDecode(t *testing.T) {
+ // Serialize one small and one multi-input transaction as shared
+ // decode inputs.
+ var smallTxBuf, multiTxBuf bytes.Buffer
+ require.NoError(t, blockOne.Transactions[0].Serialize(&smallTxBuf))
+ require.NoError(t, multiTx.Serialize(&multiTxBuf))
+
+ const numWorkers = 16
+ const decodesPerWorker = 200
+
+ var wg sync.WaitGroup
+ errs := make(chan error, numWorkers)
+ for w := 0; w < numWorkers; w++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for i := 0; i < decodesPerWorker; i++ {
+ var tx1, tx2 MsgTx
+ err := tx1.Deserialize(
+ bytes.NewReader(smallTxBuf.Bytes()),
+ )
+ if err != nil {
+ errs <- err
+ return
+ }
+ err = tx2.Deserialize(
+ bytes.NewReader(multiTxBuf.Bytes()),
+ )
+ if err != nil {
+ errs <- err
+ return
+ }
+
+ // Verify the decoded scripts match the
+ // originals so cross-arena aliasing would
+ // surface as corruption.
+ if !bytes.Equal(
+ tx2.TxIn[0].SignatureScript,
+ multiTx.TxIn[0].SignatureScript,
+ ) {
+ errs <- fmt.Errorf("cross-arena " +
+ "aliasing: decoded script " +
+ "mismatch")
+ return
+ }
+ }
+ }()
+ }
+ wg.Wait()
+ close(errs)
+ for err := range errs {
+ require.NoError(t, err)
+ }
+}
+
+// TestScriptArenaRandomizedAllocations drives the arena with a deterministic
+// random allocation/rewind pattern and checks that live allocations never
+// alias each other.
+func TestScriptArenaRandomizedAllocations(t *testing.T) {
+ rng := rand.New(rand.NewSource(42))
+
+ for round := 0; round < 50; round++ {
+ ar := borrowScriptArena()
+
+ for rewinds := 0; rewinds < 4; rewinds++ {
+ var live [][]byte
+ for ar.remaining() > 0 && rng.Float64() > 0.05 {
+ n := rng.Intn(64 << 10)
+ if n > ar.remaining() {
+ n = ar.remaining()
+ }
+
+ s, err := ar.alloc(n)
+ require.NoError(t, err)
+ require.Len(t, s, n)
+
+ marker := byte(len(live))
+ for i := range s {
+ s[i] = marker
+ }
+ live = append(live, s)
+ }
+
+ // All live allocations must retain their markers.
+ for marker, s := range live {
+ require.Equal(
+ t, len(s),
+ bytes.Count(s, []byte{byte(marker)}),
+ )
+ }
+
+ ar.rewind()
+ require.LessOrEqual(t, len(ar.chunks), 1)
+ }
+
+ ar.release()
+ }
+}
### wire/bench_parallel_test.go
@@ -0,0 +1,125 @@
+// Copyright (c) 2026 The btcsuite developers
+// Use of this source code is governed by an ISC
+// license that can be found in the LICENSE file.
+
+package wire
+
+import (
+ "bytes"
+ "compress/bzip2"
+ "io"
+ "os"
+ "testing"
+)
+
+// smallTxBytes returns the serialization of a typical small transaction,
+// mirroring the payload used by BenchmarkDeserializeTxSmall.
+func smallTxBytes() []byte {
+ return []byte{
+ 0x01, 0x00, 0x00, 0x00, // Version
+ 0x01, // Varint for number of input transactions
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Previous output hash
+ 0xff, 0xff, 0xff, 0xff, // Previous output index
+ 0x07, // Varint for length of signature script
+ 0x04, 0xff, 0xff, 0x00, 0x1d, 0x01, 0x04, // Signature script
+ 0xff, 0xff, 0xff, 0xff, // Sequence
+ 0x01, // Varint for number of output transactions
+ 0x00, 0xf2, 0x05, 0x2a, 0x01, 0x00, 0x00, 0x00, // Transaction amount
+ 0x43, // Varint for length of pk script
+ 0x41, // OP_DATA_65
+ 0x04, 0x96, 0xb5, 0x38, 0xe8, 0x53, 0x51, 0x9c,
+ 0x72, 0x6a, 0x2c, 0x91, 0xe6, 0x1e, 0xc1, 0x16,
+ 0x00, 0xae, 0x13, 0x90, 0x81, 0x3a, 0x62, 0x7c,
+ 0x66, 0xfb, 0x8b, 0xe7, 0x94, 0x7b, 0xe6, 0x3c,
+ 0x52, 0xda, 0x75, 0x89, 0x37, 0x95, 0x15, 0xd4,
+ 0xe0, 0xa6, 0x04, 0xf8, 0x14, 0x17, 0x81, 0xe6,
+ 0x22, 0x94, 0x72, 0x11, 0x66, 0xbf, 0x62, 0x1e,
+ 0x73, 0xa8, 0x2c, 0xbf, 0x23, 0x42, 0xc8, 0x58,
+ 0xee, // 65-byte signature
+ 0xac, // OP_CHECKSIG
+ 0x00, 0x00, 0x00, 0x00, // Lock time
+ }
+}
+
+// BenchmarkDeserializeTxSmallParallel measures small transaction decoding
+// with all CPUs decoding concurrently, which models many peers relaying
+// mempool transactions at once. This stresses contention on the shared
+// script staging pool as well as the transient memory borrowed per decode.
+func BenchmarkDeserializeTxSmallParallel(b *testing.B) {
+ buf := smallTxBytes()
+
+ b.ReportAllocs()
+ b.ResetTimer()
+
+ b.RunParallel(func(pb *testing.PB) {
+ r := bytes.NewReader(buf)
+ var tx MsgTx
+ for pb.Next() {
+ r.Seek(0, 0)
+ if err := tx.Deserialize(r); err != nil {
+ b.Error(err)
+ return
+ }
+ }
+ })
+}
+
+// BenchmarkDeserializeTxLargeParallel measures concurrent decoding of a
+// very large transaction, exercising the staging buffer growth path from
+// every CPU at once.
+func BenchmarkDeserializeTxLargeParallel(b *testing.B) {
+ fi, err := os.Open("testdata/megatx.bin.bz2")
+ if err != nil {
+ b.Fatalf("Failed to read transaction data: %v", err)
+ }
+ defer fi.Close()
+ buf, err := io.ReadAll(bzip2.NewReader(fi))
+ if err != nil {
+ b.Fatalf("Failed to read transaction data: %v", err)
+ }
+
+ b.ReportAllocs()
+ b.ResetTimer()
+
+ b.RunParallel(func(pb *testing.PB) {
+ r := bytes.NewReader(buf)
+ var tx MsgTx
+ for pb.Next() {
+ r.Seek(0, 0)
+ if err := tx.Deserialize(r); err != nil {
+ b.Error(err)
+ return
+ }
+ }
+ })
+}
+
+// BenchmarkDeserializeBlockParallel measures concurrent decoding of a full
+// mainnet block from every CPU at once, which models parallel block
+// download during initial block sync.
+func BenchmarkDeserializeBlockParallel(b *testing.B) {
+ buf, err := os.ReadFile(
+ "testdata/block-00000000000000000021868c2cefc52a480d173c849412fe81c4e5ab806f94ab.blk",
+ )
+ if err != nil {
+ b.Fatalf("Failed to read block data: %v", err)
+ }
+
+ b.ReportAllocs()
+ b.ResetTimer()
+
+ b.RunParallel(func(pb *testing.PB) {
+ r := bytes.NewReader(buf)
+ var block MsgBlock
+ for pb.Next() {
+ r.Seek(0, 0)
+ if err := block.Deserialize(r); err != nil {
+ b.Error(err)
+ return
+ }
+ }
+ })
+}
### wire/bench_test.go
@@ -445,8 +445,7 @@ func BenchmarkReadTxOut(b *testing.B) {
func BenchmarkReadTxOutBuf(b *testing.B) {
b.ReportAllocs()
- scriptBuffer := scriptPool.Borrow()
- sbuf := scriptBuffer[:]
+ ar := borrowScriptArena()
buffer := binarySerializer.Borrow()
buf := []byte{
0x00, 0xf2, 0x05, 0x2a, 0x01, 0x00, 0x00, 0x00, // Transaction amount
@@ -467,10 +466,11 @@ func BenchmarkReadTxOutBuf(b *testing.B) {
var txOut TxOut
for i := 0; i < b.N; i++ {
r.Seek(0, 0)
- readTxOutBuf(r, 0, 0, &txOut, buffer, sbuf)
+ ar.rewind()
+ readTxOutBuf(r, 0, 0, &txOut, buffer, ar)
}
binarySerializer.Return(buffer)
- scriptPool.Return(scriptBuffer)
+ ar.release()
}
// BenchmarkWriteTxOut performs a benchmark on how long it takes to write
@@ -502,8 +502,7 @@ func BenchmarkWriteTxOutBuf(b *testing.B) {
func BenchmarkReadTxIn(b *testing.B) {
b.ReportAllocs()
- scriptBuffer := scriptPool.Borrow()
- sbuf := scriptBuffer[:]
+ ar := borrowScriptArena()
buffer := binarySerializer.Borrow()
buf := []byte{
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -519,10 +518,11 @@ func BenchmarkReadTxIn(b *testing.B) {
var txIn TxIn
for i := 0; i < b.N; i++ {
r.Seek(0, 0)
- readTxInBuf(r, 0, 0, &txIn, buffer, sbuf)
+ ar.rewind()
+ readTxInBuf(r, 0, 0, &txIn, buffer, ar)
}
binarySerializer.Return(buffer)
- scriptPool.Return(scriptBuffer)
+ ar.release()
}
// BenchmarkWriteTxIn performs a benchmark on how long it takes to write
### wire/common.go
@@ -5,6 +5,7 @@
package wire
import (
+ "bytes"
"crypto/rand"
"encoding/binary"
"fmt"
@@ -22,8 +23,134 @@ const (
// binaryFreeListMaxItems is the number of buffers to keep in the free
// list to use for binary serialization and deserialization.
binaryFreeListMaxItems = 1024
+
+ // defaultReadBufferSize bounds speculative allocation when a reader does
+ // not expose the number of bytes it has remaining.
+ defaultReadBufferSize = 4096
+
+ // defaultStreamingElementCap bounds speculative vector allocation when a
+ // reader does not expose the number of bytes it has remaining.
+ defaultStreamingElementCap = 128
)
+// readBytes reads count bytes without allocating the full claimed size until
+// the reader proves that the bytes are available. Buffered message decoders
+// expose their remaining length, which preserves the single allocation fast
+// path for complete payloads. Streaming readers grow in small increments as
+// data arrives.
+func readBytes(r io.Reader, count uint64) ([]byte, error) {
+ if count == 0 {
+ return []byte{}, nil
+ }
+
+ available, known := readerRemaining(r)
+ if known && count <= available {
+ result := make([]byte, count)
+ n, err := io.ReadFull(r, result)
+ return result[:n], err
+ }
+
+ // Read into bounded chunks rather than using io.CopyN with a
+ // bytes.Buffer. Buffer.ReadFrom reserves more space before its final EOF
+ // check, which can grow the buffer beyond count after a complete read.
+ // Each chunk below is capped by the remaining payload and is allocated
+ // only after the preceding bytes arrive.
+ remaining := count
+ chunkSize := uint64(defaultReadBufferSize)
+
+ // The current payload maximum needs fewer than 16 geometrically sized
+ // chunks, so the chunk metadata does not need to grow while reading.
+ chunks := make([][]byte, 0, 16)
+ totalRead := 0
+ for remaining > 0 {
+ chunkSize = min(chunkSize, remaining)
+ chunk := make([]byte, int(chunkSize))
+ n := 0
+ var err error
+ for n < len(chunk) && err == nil {
+ var bytesRead int
+ bytesRead, err = r.Read(chunk[n:])
+ n += bytesRead
+ }
+
+ chunks = append(chunks, chunk[:n])
+ totalRead += n
+ remaining -= uint64(n)
+ if err != nil && remaining > 0 {
+ if err == io.EOF && totalRead > 0 {
+ err = io.ErrUnexpectedEOF
+ }
+ if len(chunks) == 1 {
+ return chunks[0], err
+ }
+
+ return bytes.Join(chunks, nil), err
+ }
+
+ chunkSize *= 2
+ }
+
+ if len(chunks) == 1 {
+ return chunks[0], nil
+ }
+
+ return bytes.Join(chunks, nil), nil
+}
+
+// readerRemaining returns the number of buffered bytes exposed by the concrete
+// readers used by wire decoders. Other readers use the staged streaming path.
+func readerRemaining(r io.Reader) (uint64, bool) {
+ switch r := r.(type) {
+ case *bytes.Buffer:
+ return uint64(r.Len()), true
+
+ case *bytes.Reader:
+ return uint64(r.Len()), true
+
+ default:
+ return 0, false
+ }
+}
+
+// validateElementCount ensures a buffered payload has enough bytes remaining
+// to encode the claimed number of elements before a decoder allocates for the
+// count. The framed wire path passes a bytes.Buffer to each message decoder,
+// while callers without a measurable remainder retain streaming semantics.
+func validateElementCount(r io.Reader, count,
+ minElementSize uint64) error {
+
+ _, err := canPreallocateElements(r, count, minElementSize)
+ return err
+}
+
+// canPreallocateElements reports whether a reader proves that the minimum
+// encoding for count elements is already buffered. Readers without a
+// measurable remainder must grow element storage as decoding makes progress.
+func canPreallocateElements(r io.Reader, count,
+ minElementSize uint64) (bool, error) {
+
+ remaining, ok := readerRemaining(r)
+ if !ok || minElementSize == 0 {
+ return false, nil
+ }
+
+ if count <= remaining/minElementSize {
+ return true, nil
+ }
+
+ return false, elementCountError(remaining)
+}
+
+// elementCountError preserves the short-read error returned by the element
+// decoder when the claimed vector cannot fit in the remaining payload.
+func elementCountError(remaining uint64) error {
+ if remaining == 0 {
+ return io.EOF
+ }
+
+ return io.ErrUnexpectedEOF
+}
+
var (
// littleEndian is a convenience variable since binary.LittleEndian is
// quite long.
@@ -643,11 +770,7 @@ func ReadVarString(r io.Reader, pver uint32) (string, error) {
// maximum block payload size since it helps protect against memory exhaustion
// attacks and forced panics through malformed messages.
//
-// If b is non-nil, the provided buffer will be used for serializing small
-// values. Otherwise a buffer will be drawn from the binarySerializer's pool
-// and return when the method finishes.
-//
-// NOTE: b MUST either be nil or at least an 8-byte slice.
+// NOTE: buf MUST be at least an 8-byte slice.
func readVarStringBuf(r io.Reader, pver uint32, buf []byte) (string, error) {
count, err := ReadVarIntBuf(r, pver, buf)
if err != nil {
@@ -663,8 +786,7 @@ func readVarStringBuf(r io.Reader, pver uint32, buf []byte) (string, error) {
return "", messageError("ReadVarString", str)
}
- str := make([]byte, count)
- _, err = io.ReadFull(r, str)
+ str, err := readBytes(r, count)
if err != nil {
return "", err
}
@@ -686,11 +808,7 @@ func WriteVarString(w io.Writer, pver uint32, str string) error {
// the length of the string followed by the bytes that represent the string
// itself.
//
-// If b is non-nil, the provided buffer will be used for serializing small
-// values. Otherwise a buffer will be drawn from the binarySerializer's pool
-// and return when the method finishes.
-//
-// NOTE: b MUST either be nil or at least an 8-byte slice.
+// NOTE: buf MUST be at least an 8-byte slice.
func writeVarStringBuf(w io.Writer, pver uint32, str string, buf []byte) error {
err := WriteVarIntBuf(w, pver, uint64(len(str)), buf)
if err != nil {
@@ -724,9 +842,9 @@ func ReadVarBytes(r io.Reader, pver uint32, maxAllowed uint32,
// passed maxAllowed parameter which helps protect against memory exhaustion
// attacks and forced panics through malformed messages. The fieldName
// parameter is only used for the error message so it provides more context in
-// the error. If b is non-nil, the provided buffer will be used for serializing
-// small values. Otherwise a buffer will be drawn from the binarySerializer's
-// pool and return when the method finishes.
+// the error.
+//
+// NOTE: buf MUST be at least an 8-byte slice.
func ReadVarBytesBuf(r io.Reader, pver uint32, buf []byte, maxAllowed uint32,
fieldName string) ([]byte, error) {
@@ -744,11 +862,11 @@ func ReadVarBytesBuf(r io.Reader, pver uint32, buf []byte, maxAllowed uint32,
return nil, messageError("ReadVarBytes", str)
}
- bytes := make([]byte, count)
- _, err = io.ReadFull(r, bytes)
+ bytes, err := readBytes(r, count)
if err != nil {
return nil, err
}
+
return bytes, nil
}
### wire/decode_budget_bench_test.go
@@ -0,0 +1,228 @@
+// Copyright (c) 2026 The btcsuite developers
+// Use of this source code is governed by an ISC
+// license that can be found in the LICENSE file.
+
+package wire
+
+import (
+ "bytes"
+ "os"
+ "testing"
+)
+
+func benchmarkCountPayload(b *testing.B, prefix []byte, count uint64) []byte {
+ b.Helper()
+
+ var payload bytes.Buffer
+ if _, err := payload.Write(prefix); err != nil {
+ b.Fatalf("unable to write payload prefix: %v", err)
+ }
+ if err := WriteVarInt(&payload, ProtocolVersion, count); err != nil {
+ b.Fatalf("unable to write element count: %v", err)
+ }
+
+ return payload.Bytes()
+}
+
+func benchmarkCompleteStreamingMessage(b *testing.B, msg Message) {
+ b.Helper()
+
+ var encoded bytes.Buffer
+ _, err := WriteMessageWithEncodingN(
+ &encoded, msg, ProtocolVersion, MainNet, WitnessEncoding,
+ )
+ if err != nil {
+ b.Fatal(err)
+ }
+ payload := encoded.Bytes()
+
+ b.SetBytes(int64(len(payload)))
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ reader := &readSizeRecorder{
+ reader: bytes.NewReader(payload),
+ }
+ _, _, _, err := ReadMessageWithEncodingN(
+ reader, ProtocolVersion, MainNet, WitnessEncoding,
+ )
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+// BenchmarkCompleteStreamingMessage measures complete framed messages read
+// from a reader that does not expose its remaining length.
+func BenchmarkCompleteStreamingMessage(b *testing.B) {
+ b.Run("ping", func(b *testing.B) {
+ benchmarkCompleteStreamingMessage(b, NewMsgPing(1))
+ })
+
+ blockBytes, err := os.ReadFile(
+ "testdata/block-00000000000000000021868c2cefc52a480d173c849412fe81c4e5ab806f94ab.blk",
+ )
+ if err != nil {
+ b.Fatal(err)
+ }
+ var block MsgBlock
+ if err := block.Deserialize(bytes.NewReader(blockBytes)); err != nil {
+ b.Fatal(err)
+ }
+ b.Run("block", func(b *testing.B) {
+ benchmarkCompleteStreamingMessage(b, &block)
+ })
+}
+
+// BenchmarkTruncatedDecodeBudget measures allocation when a length or element
+// count is present without the bytes needed to satisfy it.
+func BenchmarkTruncatedDecodeBudget(b *testing.B) {
+ b.Run("message_payload", func(b *testing.B) {
+ header := makeHeader(
+ MainNet, CmdBlock, MaxProtocolMessageLength, 0,
+ )
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, _, _, err := ReadMessageN(
+ bytes.NewReader(header), ProtocolVersion, MainNet,
+ )
+ if err == nil {
+ b.Fatal("expected a truncated payload error")
+ }
+ }
+ })
+
+ b.Run("variable_bytes", func(b *testing.B) {
+ payload := benchmarkCountPayload(
+ b, nil, MaxProtocolMessageLength,
+ )
+ payload = append(payload, 0x01)
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, err := ReadVarBytes(
+ bytes.NewReader(payload), ProtocolVersion,
+ MaxMessagePayload, "payload",
+ )
+ if err == nil {
+ b.Fatal("expected a truncated byte payload error")
+ }
+ }
+ })
+
+ b.Run("inventory", func(b *testing.B) {
+ payload := benchmarkCountPayload(b, nil, MaxInvPerMsg)
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ var msg MsgInv
+ err := msg.BtcDecode(
+ bytes.NewReader(payload), ProtocolVersion, BaseEncoding,
+ )
+ if err == nil {
+ b.Fatal("expected a truncated inventory error")
+ }
+ }
+ })
+
+ b.Run("merkle_hashes", func(b *testing.B) {
+ // The 80-byte block header followed by the 4-byte total transaction
+ // count are read by MsgMerkleBlock.BtcDecode before the hash count
+ // varint.
+ payload := benchmarkCountPayload(
+ b, make([]byte, MaxBlockHeaderPayload+4), maxTxPerBlock,
+ )
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ var msg MsgMerkleBlock
+ err := msg.BtcDecode(
+ bytes.NewReader(payload), ProtocolVersion, BaseEncoding,
+ )
+ if err == nil {
+ b.Fatal("expected a truncated merkle block error")
+ }
+ }
+ })
+
+ b.Run("transaction_inputs", func(b *testing.B) {
+ // The 4-byte transaction version is read by MsgTx.BtcDecode before
+ // the input count varint.
+ payload := benchmarkCountPayload(
+ b, make([]byte, 4), maxTxInPerMessage,
+ )
+
+ // Prewarm the script arena so the borrow and release pair populates
+ // the chunk pool, and the benchmark measures only the count-sized
+ // transaction input allocation made while decoding rather than the
+ // one-time chunk creation.
+ ar := borrowScriptArena()
+ ar.release()
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ var msg MsgTx
+ err := msg.BtcDecode(
+ bytes.NewReader(payload), ProtocolVersion, WitnessEncoding,
+ )
+ if err == nil {
+ b.Fatal("expected a truncated transaction input error")
+ }
+ }
+ })
+
+ b.Run("streaming_transaction_inputs", func(b *testing.B) {
+ // The 4-byte transaction version is read by MsgTx.BtcDecode before
+ // the input count varint.
+ payload := benchmarkCountPayload(
+ b, make([]byte, 4), maxTxInPerMessage,
+ )
+
+ ar := borrowScriptArena()
+ ar.release()
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ reader := &readSizeRecorder{
+ reader: bytes.NewReader(payload),
+ }
+ var msg MsgTx
+ err := msg.BtcDecode(
+ reader, ProtocolVersion, WitnessEncoding,
+ )
+ if err == nil {
+ b.Fatal("expected a truncated transaction input error")
+ }
+ }
+ })
+
+ b.Run("streaming_filter_checkpoints", func(b *testing.B) {
+ // The 1-byte filter type followed by the 32-byte stop hash are read
+ // by MsgCFCheckpt.BtcDecode before the filter header count varint.
+ payload := benchmarkCountPayload(
+ b, make([]byte, 33), maxCFHeadersLen,
+ )
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ reader := &readSizeRecorder{
+ reader: bytes.NewReader(payload),
+ }
+ var msg MsgCFCheckpt
+ err := msg.BtcDecode(
+ reader, ProtocolVersion, BaseEncoding,
+ )
+ if err == nil {
+ b.Fatal("expected a truncated checkpoint error")
+ }
+ }
+ })
+}
### wire/decode_budget_test.go
@@ -0,0 +1,613 @@
+// Copyright (c) 2026 The btcsuite developers
+// Use of this source code is governed by an ISC
+// license that can be found in the LICENSE file.
+
+package wire
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "runtime"
+ "testing"
+
+ "github.com/btcsuite/btcd/chainhash/v2"
+)
+
+func countPayload(t *testing.T, prefix []byte, count uint64) []byte {
+ t.Helper()
+
+ var payload bytes.Buffer
+ _, err := payload.Write(prefix)
+ if err != nil {
+ t.Fatalf("unable to write payload prefix: %v", err)
+ }
+ if err := WriteVarInt(&payload, ProtocolVersion, count); err != nil {
+ t.Fatalf("unable to write element count: %v", err)
+ }
+
+ return payload.Bytes()
+}
+
+// TestTruncatedVectorDecodeBudget ensures a legal maximum element count cannot
+// reserve the corresponding maximum slice when no elements follow it. Each
+// decode must fail on the truncated payload while having reserved no element
+// storage at all.
+func TestTruncatedVectorDecodeBudget(t *testing.T) {
+ tests := []struct {
+ name string
+ payload []byte
+ decode func(io.Reader) (int, error)
+ }{
+ {
+ name: "inventory",
+ payload: countPayload(t, nil, MaxInvPerMsg),
+ decode: func(r io.Reader) (int, error) {
+ var msg MsgInv
+ err := msg.BtcDecode(r, ProtocolVersion, BaseEncoding)
+ return cap(msg.InvList), err
+ },
+ },
+ {
+ name: "getdata",
+ payload: countPayload(t, nil, MaxInvPerMsg),
+ decode: func(r io.Reader) (int, error) {
+ var msg MsgGetData
+ err := msg.BtcDecode(r, ProtocolVersion, BaseEncoding)
+ return cap(msg.InvList), err
+ },
+ },
+ {
+ name: "notfound",
+ payload: countPayload(t, nil, MaxInvPerMsg),
+ decode: func(r io.Reader) (int, error) {
+ var msg MsgNotFound
+ err := msg.BtcDecode(r, ProtocolVersion, BaseEncoding)
+ return cap(msg.InvList), err
+ },
+ },
+ {
+ name: "addresses",
+ payload: countPayload(t, nil, MaxAddrPerMsg),
+ decode: func(r io.Reader) (int, error) {
+ var msg MsgAddr
+ err := msg.BtcDecode(r, ProtocolVersion, BaseEncoding)
+ return cap(msg.AddrList), err
+ },
+ },
+ {
+ name: "addresses v2",
+ payload: countPayload(t, nil, MaxV2AddrPerMsg),
+ decode: func(r io.Reader) (int, error) {
+ var msg MsgAddrV2
+ err := msg.BtcDecode(r, ProtocolVersion, BaseEncoding)
+ return cap(msg.AddrList), err
+ },
+ },
+ {
+ name: "headers",
+ payload: countPayload(t, nil, MaxBlockHeadersPerMsg),
+ decode: func(r io.Reader) (int, error) {
+ var msg MsgHeaders
+ err := msg.BtcDecode(r, ProtocolVersion, BaseEncoding)
+ return cap(msg.Headers), err
+ },
+ },
+ {
+ name: "getblocks locators",
+ payload: countPayload(
+ t, make([]byte, 4), MaxBlockLocatorsPerMsg,
+ ),
+ decode: func(r io.Reader) (int, error) {
+ var msg MsgGetBlocks
+ err := msg.BtcDecode(r, ProtocolVersion, BaseEncoding)
+ return cap(msg.BlockLocatorHashes), err
+ },
+ },
+ {
+ name: "getheaders locators",
+ payload: countPayload(
+ t, make([]byte, 4), MaxBlockLocatorsPerMsg,
+ ),
+ decode: func(r io.Reader) (int, error) {
+ var msg MsgGetHeaders
+ err := msg.BtcDecode(r, ProtocolVersion, BaseEncoding)
+ return cap(msg.BlockLocatorHashes), err
+ },
+ },
+ {
+ name: "compact filter headers",
+ payload: countPayload(
+ t, make([]byte, 65), MaxCFHeadersPerMsg,
+ ),
+ decode: func(r io.Reader) (int, error) {
+ var msg MsgCFHeaders
+ err := msg.BtcDecode(r, ProtocolVersion, BaseEncoding)
+ return cap(msg.FilterHashes), err
+ },
+ },
+ {
+ name: "compact filter checkpoints",
+ payload: countPayload(
+ t, make([]byte, 33), maxCFHeadersLen,
+ ),
+ decode: func(r io.Reader) (int, error) {
+ var msg MsgCFCheckpt
+ err := msg.BtcDecode(r, ProtocolVersion, BaseEncoding)
+ return cap(msg.FilterHeaders), err
+ },
+ },
+ {
+ name: "merkle block hashes",
+ payload: countPayload(
+ t, make([]byte, MaxBlockHeaderPayload+4), maxTxPerBlock,
+ ),
+ decode: func(r io.Reader) (int, error) {
+ var msg MsgMerkleBlock
+ err := msg.BtcDecode(r, ProtocolVersion, BaseEncoding)
+ return cap(msg.Hashes), err
+ },
+ },
+ {
+ name: "block transactions",
+ payload: countPayload(
+ t, make([]byte, MaxBlockHeaderPayload), maxTxPerBlock,
+ ),
+ decode: func(r io.Reader) (int, error) {
+ var msg MsgBlock
+ err := msg.BtcDecode(r, ProtocolVersion, BaseEncoding)
+ return cap(msg.Transactions), err
+ },
+ },
+ {
+ name: "block transaction locations",
+ payload: countPayload(
+ t, make([]byte, MaxBlockHeaderPayload), maxTxPerBlock,
+ ),
+ decode: func(r io.Reader) (int, error) {
+ // DeserializeTxLoc takes a *bytes.Buffer rather
+ // than a generic io.Reader, so copy the payload
+ // into one. The copy is made by the test, not
+ // the decode being measured.
+ data, readErr := io.ReadAll(r)
+ if readErr != nil {
+ return 0, readErr
+ }
+ var msg MsgBlock
+ _, err := msg.DeserializeTxLoc(bytes.NewBuffer(data))
+ return cap(msg.Transactions), err
+ },
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ capacity, err := test.decode(bytes.NewReader(test.payload))
+ if err == nil {
+ t.Fatal("expected a truncated payload error")
+ }
+ if capacity != 0 {
+ t.Fatalf("decode reserved %d elements, want 0",
+ capacity)
+ }
+ })
+ }
+}
+
+// TestTruncatedTxDecodeBudget covers each count-driven transaction slice.
+func TestTruncatedTxDecodeBudget(t *testing.T) {
+ // Inspect the internal decoder before BtcDecode clears failed results,
+ // so public error cleanup cannot hide an oversized reservation.
+ decode := func(t *testing.T, payload []byte) (*MsgTx, error) {
+ t.Helper()
+ ar := borrowScriptArena()
+ t.Cleanup(ar.release)
+ buf := binarySerializer.Borrow()
+ t.Cleanup(func() { binarySerializer.Return(buf) })
+ msg := new(MsgTx)
+ err := msg.btcDecode(
+ bytes.NewReader(payload), ProtocolVersion, WitnessEncoding,
+ buf, ar,
+ )
+ return msg, err
+ }
+
+ t.Run("inputs", func(t *testing.T) {
+ payload := countPayload(t, make([]byte, 4), maxTxInPerMessage)
+ msg, err := decode(t, payload)
+ if err == nil {
+ t.Fatal("expected a truncated input error")
+ }
+ if len(msg.TxIn) != 0 {
+ t.Fatalf("decode retained %d inputs", len(msg.TxIn))
+ }
+ })
+
+ inputPrefix := make([]byte, 4)
+ inputPrefix = append(inputPrefix, 1)
+ inputPrefix = append(inputPrefix, make([]byte, minTxInPayload)...)
+
+ t.Run("outputs", func(t *testing.T) {
+ payload := countPayload(t, inputPrefix, maxTxOutPerMessage)
+ msg, err := decode(t, payload)
+ if err == nil {
+ t.Fatal("expected a truncated output error")
+ }
+ if len(msg.TxOut) != 0 {
+ t.Fatalf("decode retained %d outputs", len(msg.TxOut))
+ }
+ })
+
+ t.Run("witness items", func(t *testing.T) {
+ witnessPrefix := make([]byte, 4)
+ witnessPrefix = append(witnessPrefix, TxFlagMarker, WitnessFlag, 1)
+ witnessPrefix = append(witnessPrefix, make([]byte, minTxInPayload)...)
+ witnessPrefix = append(witnessPrefix, 0)
+ payload := countPayload(
+ t, witnessPrefix, maxWitnessItemsPerInput,
+ )
+
+ msg, err := decode(t, payload)
+ if err == nil {
+ t.Fatal("expected a truncated witness error")
+ }
+ if len(msg.TxIn) != 1 {
+ t.Fatalf("decoded %d inputs, want 1", len(msg.TxIn))
+ }
+ if capacity := cap(msg.TxIn[0].Witness); capacity >
+ defaultTxInOutAlloc {
+
+ t.Fatalf("decode reserved %d witness items, want at most %d",
+ capacity, defaultTxInOutAlloc)
+ }
+ })
+}
+
+// TestTruncatedVariableBytesBudget ensures inner length prefixes grow memory
+// with bytes delivered rather than with the claimed length.
+func TestTruncatedVariableBytesBudget(t *testing.T) {
+ var encoded bytes.Buffer
+ if err := WriteVarInt(&encoded, ProtocolVersion, MaxMessagePayload); err != nil {
+ t.Fatalf("unable to encode length: %v", err)
+ }
+ prefix := encoded.Bytes()
+
+ truncated := append(bytes.Clone(prefix), 0x01)
+ reader := &readSizeRecorder{reader: bytes.NewReader(truncated)}
+ decoded, err := ReadVarBytes(
+ reader, ProtocolVersion, MaxMessagePayload,
+ "payload",
+ )
+ if err == nil {
+ t.Fatal("expected a truncated byte payload error")
+ }
+ if decoded != nil {
+ t.Fatalf("decoded %d bytes on error", len(decoded))
+ }
+ if reader.max > defaultReadBufferSize {
+ t.Fatalf("byte payload read requested %d bytes, want at most %d",
+ reader.max, defaultReadBufferSize)
+ }
+
+ reader = &readSizeRecorder{reader: bytes.NewReader(truncated)}
+ decodedString, err := ReadVarString(
+ reader, ProtocolVersion,
+ )
+ if err == nil {
+ t.Fatal("expected a truncated string error")
+ }
+ if decodedString != "" {
+ t.Fatalf("decoded %d string bytes on error", len(decodedString))
+ }
+ if reader.max > defaultReadBufferSize {
+ t.Fatalf("string payload read requested %d bytes, want at most %d",
+ reader.max, defaultReadBufferSize)
+ }
+
+ result, err := readBytes(
+ &readSizeRecorder{reader: bytes.NewReader([]byte{0x01})},
+ MaxMessagePayload,
+ )
+ if err == nil {
+ t.Fatal("expected a truncated direct read error")
+ }
+ if len(result) != 1 {
+ t.Fatalf("direct read returned %d bytes, want 1", len(result))
+ }
+ if capacity := cap(result); capacity > defaultReadBufferSize {
+ t.Fatalf("direct read reserved %d bytes, want at most %d", capacity,
+ defaultReadBufferSize)
+ }
+
+ // A read that claims the maximum but delivers progressively more
+ // bytes must reserve memory proportional to what was delivered, not
+ // to what was claimed: each truncated read hands back only the bytes
+ // that actually arrived, staged in bounded chunks.
+ for _, delivered := range []int{
+ 1, defaultReadBufferSize, 3*defaultReadBufferSize + 7,
+ } {
+ result, err := readBytes(
+ &readSizeRecorder{reader: bytes.NewReader(
+ make([]byte, delivered),
+ )},
+ MaxMessagePayload,
+ )
+ if !errors.Is(err, io.ErrUnexpectedEOF) {
+ t.Fatalf("delivered %d bytes: unexpected error: got %v, "+
+ "want %v", delivered, err, io.ErrUnexpectedEOF)
+ }
+ if len(result) != delivered {
+ t.Fatalf("delivered %d bytes: read returned %d bytes, "+
+ "want %d", delivered, len(result), delivered)
+ }
+
+ // The staged chunks hold the delivered bytes in geometrically
+ // sized pieces, so the total reservation may exceed the
+ // delivered length by at most one final chunk.
+ if capacity := cap(result); capacity > delivered+defaultReadBufferSize {
+ t.Fatalf("delivered %d bytes: read reserved %d bytes, "+
+ "want at most %d", delivered, capacity,
+ delivered+defaultReadBufferSize)
+ }
+ }
+}
+
+// TestCompleteStreamingBytesCapacity ensures a complete opaque read returns an
+// exactly sized payload instead of retaining capacity beyond its length.
+func TestCompleteStreamingBytesCapacity(t *testing.T) {
+ payload := bytes.Repeat(
+ []byte{0x01}, defaultReadBufferSize+1,
+ )
+ reader := &readSizeRecorder{reader: bytes.NewReader(payload)}
+ result, err := readBytes(reader, uint64(len(payload)))
+ if err != nil {
+ t.Fatalf("unable to read complete payload: %v", err)
+ }
+ if !bytes.Equal(result, payload) {
+ t.Fatal("decoded payload differs from input")
+ }
+ if cap(result) != len(result) {
+ t.Fatalf("payload capacity is %d, want %d", cap(result), len(result))
+ }
+}
+
+// TestStreamingBytesPreservesBoundaryError ensures an error returned with a
+// full internal chunk is not discarded while the payload remains incomplete.
+func TestStreamingBytesPreservesBoundaryError(t *testing.T) {
+ errBoundary := errors.New("chunk boundary")
+ reader := &boundaryErrorReader{
+ payload: bytes.Repeat([]byte{0x01}, defaultReadBufferSize),
+ err: errBoundary,
+ }
+ result, err := readBytes(reader, defaultReadBufferSize+1)
+ if !errors.Is(err, errBoundary) {
+ t.Fatalf("unexpected read error: got %v, want %v", err, errBoundary)
+ }
+ if len(result) != defaultReadBufferSize {
+ t.Fatalf("payload length is %d, want %d", len(result),
+ defaultReadBufferSize)
+ }
+ if reader.reads != 1 {
+ t.Fatalf("reader called %d times, want 1", reader.reads)
+ }
+}
+
+// readSizeRecorder wraps a reader so decoders cannot measure the number of
+// bytes it has remaining, which forces every speculative-read guard to take
+// its streaming path. It also records the largest single read the caller
+// requested, so a decode that reserved or demanded a huge buffer up front is
+// detectable as an oversized request rather than only as an allocation.
+type readSizeRecorder struct {
+ reader io.Reader
+ max int
+}
+
+func (r *readSizeRecorder) Read(p []byte) (int, error) {
+ if len(p) > r.max {
+ r.max = len(p)
+ }
+
+ return r.reader.Read(p)
+}
+
+type boundaryErrorReader struct {
+ payload []byte
+ err error
+ reads int
+}
+
+func (r *boundaryErrorReader) Read(p []byte) (int, error) {
+ r.reads++
+ if r.reads > 1 {
+ return 0, io.EOF
+ }
+
+ return copy(p, r.payload), r.err
+}
+
+type inflatedLenReader struct {
+ reader io.Reader
+ length int
+}
+
+func (r *inflatedLenReader) Read(p []byte) (int, error) {
+ return r.reader.Read(p)
+}
+
+func (r *inflatedLenReader) Len() int {
+ return r.length
+}
+
+// TestTruncatedMessagePayloadBudget ensures the v1 frame reader does not
+// reserve or request the full payload after receiving only its header.
+func TestTruncatedMessagePayloadBudget(t *testing.T) {
+ header := makeHeader(
+ MainNet, CmdBlock, MaxProtocolMessageLength, 0,
+ )
+ reader := &readSizeRecorder{reader: bytes.NewReader(header)}
+ _, _, _, err := ReadMessageN(reader, ProtocolVersion, MainNet)
+ if err == nil {
+ t.Fatal("expected a truncated payload error")
+ }
+ if reader.max > defaultReadBufferSize {
+ t.Fatalf("payload read requested %d bytes, want at most %d",
+ reader.max, defaultReadBufferSize)
+ }
+
+ // The header claims the protocol maximum payload, so a decoder that
+ // trusted the claim would reserve tens of megabytes before any
+ // payload bytes arrived. Measure the total bytes allocated while
+ // reading the truncated message and require the reservation to stay
+ // far below the claimed payload.
+ runs := 100
+ var before, after runtime.MemStats
+ runtime.ReadMemStats(&before)
+ for i := 0; i < runs; i++ {
+ _, _, _, err := ReadMessageN(
+ &readSizeRecorder{reader: bytes.NewReader(header)},
+ ProtocolVersion, MainNet,
+ )
+ if err == nil {
+ t.Fatal("expected a truncated payload error")
+ }
+ }
+ runtime.ReadMemStats(&after)
+ totalAlloc := after.TotalAlloc - before.TotalAlloc
+ if budget := uint64(runs) * (1 << 20); totalAlloc > budget {
+ t.Fatalf("truncated header read allocated %d bytes across %d "+
+ "runs, want at most %d (the header claims a %d byte "+
+ "payload)", totalAlloc, runs, budget,
+ MaxProtocolMessageLength)
+ }
+
+ payload := append(makeHeader(MainNet, CmdPing, 8, 0), 0x01)
+ faultingReader := &inflatedLenReader{
+ reader: bytes.NewReader(payload),
+ length: len(payload) + 8,
+ }
+ totalBytes, _, _, err := ReadMessageN(
+ faultingReader, ProtocolVersion, MainNet,
+ )
+ if err == nil {
+ t.Fatal("expected a short payload error")
+ }
+ if totalBytes != len(payload) {
+ t.Fatalf("read counted %d bytes, want %d", totalBytes, len(payload))
+ }
+}
+
+// TestStreamingTxDecodeBudget verifies that transaction vectors grow as a
+// reader without a measurable remainder supplies each element. The element
+// counts cross the initial streaming capacity to exercise backing slice
+// growth and final pointer wiring.
+func TestStreamingTxDecodeBudget(t *testing.T) {
+ const elementCount = defaultStreamingElementCap + 42
+
+ msg := NewMsgTx(2)
+ for i := 0; i < elementCount; i++ {
+ hash := chainhash.Hash{byte(i), byte(i >> 8)}
+ prevOut := NewOutPoint(&hash, uint32(i))
+ witness := make([][]byte, elementCount)
+ for j := range witness {
+ witness[j] = []byte{byte(i), byte(j)}
+ }
+
+ msg.AddTxIn(NewTxIn(
+ prevOut, []byte{0x01, byte(i)}, witness,
+ ))
+ }
+ for i := 0; i < elementCount; i++ {
+ msg.AddTxOut(NewTxOut(
+ int64(i), []byte{0x02, byte(i)},
+ ))
+ }
+
+ var encoded bytes.Buffer
+ if err := msg.Serialize(&encoded); err != nil {
+ t.Fatalf("unable to encode transaction: %v", err)
+ }
+
+ // The readSizeRecorder hides the reader's remaining length, which is
+ // what forces the decode to grow its element storage instead of
+ // preallocating the full count the way a buffered reader would.
+ reader := &readSizeRecorder{
+ reader: bytes.NewReader(encoded.Bytes()),
+ }
+ var decoded MsgTx
+ if err := decoded.BtcDecode(
+ reader, ProtocolVersion, WitnessEncoding,
+ ); err != nil {
+ t.Fatalf("unable to decode transaction: %v", err)
+ }
+
+ if len(decoded.TxIn) != elementCount {
+ t.Fatalf("decoded %d inputs, want %d", len(decoded.TxIn),
+ elementCount)
+ }
+ if cap(decoded.TxIn) > 2*elementCount {
+ t.Fatalf("streaming decode reserved %d input slots, want at "+
+ "most %d", cap(decoded.TxIn), 2*elementCount)
+ }
+
+ var reencoded bytes.Buffer
+ if err := decoded.Serialize(&reencoded); err != nil {
+ t.Fatalf("unable to re-encode transaction: %v", err)
+ }
+ if !bytes.Equal(encoded.Bytes(), reencoded.Bytes()) {
+ t.Fatal("streaming transaction decode changed the encoding")
+ }
+}
+
+// TestStreamingCFCheckptDecodeBudget verifies that compact filter checkpoint
+// hashes grow as a reader without a measurable remainder supplies them.
+func TestStreamingCFCheckptDecodeBudget(t *testing.T) {
+ const headerCount = defaultStreamingElementCap + 42
+
+ stopHash := chainhash.Hash{0x01, 0x02, 0x03}
+ msg := NewMsgCFCheckpt(GCSFilterRegular, &stopHash, headerCount)
+ for i := 0; i < headerCount; i++ {
+ header := chainhash.Hash{byte(i), byte(i >> 8), 0x04}
+ if err := msg.AddCFHeader(&header); err != nil {
+ t.Fatalf("unable to add filter header: %v", err)
+ }
+ }
+
+ var encoded bytes.Buffer
+ if err := msg.BtcEncode(
+ &encoded, ProtocolVersion, BaseEncoding,
+ ); err != nil {
+ t.Fatalf("unable to encode checkpoint: %v", err)
+ }
+
+ // As above, the recorder hides the remaining length so the checkpoint
+ // hash vector must grow as hashes arrive rather than being
+ // preallocated from the claimed count.
+ reader := &readSizeRecorder{
+ reader: bytes.NewReader(encoded.Bytes()),
+ }
+ var decoded MsgCFCheckpt
+ if err := decoded.BtcDecode(
+ reader, ProtocolVersion, BaseEncoding,
+ ); err != nil {
+ t.Fatalf("unable to decode checkpoint: %v", err)
+ }
+
+ if len(decoded.FilterHeaders) != headerCount {
+ t.Fatalf("decoded %d checkpoint headers, want %d",
+ len(decoded.FilterHeaders), headerCount)
+ }
+ if cap := cap(decoded.FilterHeaders); cap > 2*headerCount {
+ t.Fatalf("streaming decode reserved %d checkpoint slots, "+
+ "want at most %d", cap, 2*headerCount)
+ }
+
+ var reencoded bytes.Buffer
+ if err := decoded.BtcEncode(
+ &reencoded, ProtocolVersion, BaseEncoding,
+ ); err != nil {
+ t.Fatalf("unable to re-encode checkpoint: %v", err)
+ }
+ if !bytes.Equal(encoded.Bytes(), reencoded.Bytes()) {
+ t.Fatal("streaming checkpoint decode changed the encoding")
+ }
+}
### wire/fuzz_test.go
@@ -0,0 +1,116 @@
+// Copyright (c) 2026 The btcsuite developers
+// Use of this source code is governed by an ISC
+// license that can be found in the LICENSE file.
+
+package wire
+
+import (
+ "bytes"
+ "testing"
+)
+
+// FuzzTxDecode feeds arbitrary bytes to the transaction decoder, which
+// stages script data in the decode arena. Inputs that decode successfully
+// must round-trip: re-serializing and re-decoding yields the same bytes, and
+// the first decoded transaction remains stable after the second decode.
+func FuzzTxDecode(f *testing.F) {
+ // Seed with existing test vectors: the block one coinbase, the
+ // multi input/output transaction, and a witness transaction.
+ var seed bytes.Buffer
+ if err := blockOne.Transactions[0].Serialize(&seed); err != nil {
+ f.Fatal(err)
+ }
+ f.Add(bytes.Clone(seed.Bytes()))
+
+ seed.Reset()
+ if err := multiTx.Serialize(&seed); err != nil {
+ f.Fatal(err)
+ }
+ // The seed must be cloned rather than added directly: f.Add keeps a
+ // reference to the slice it is given, and reusing the buffer below
+ // would overwrite the coinbase seed before the corpus entry ever
+ // runs.
+ f.Add(bytes.Clone(seed.Bytes()))
+
+ f.Add(multiWitnessTxEncoded)
+
+ f.Fuzz(func(t *testing.T, data []byte) {
+ var tx MsgTx
+ if err := tx.Deserialize(bytes.NewReader(data)); err != nil {
+ return
+ }
+
+ var first bytes.Buffer
+ if err := tx.Serialize(&first); err != nil {
+ t.Fatalf("serialize after decode: %v", err)
+ }
+
+ var again MsgTx
+ err := again.Deserialize(bytes.NewReader(first.Bytes()))
+ if err != nil {
+ t.Fatalf("re-decode of serialized tx: %v", err)
+ }
+
+ var second bytes.Buffer
+ if err := again.Serialize(&second); err != nil {
+ t.Fatalf("serialize after re-decode: %v", err)
+ }
+ if !bytes.Equal(first.Bytes(), second.Bytes()) {
+ t.Fatal("tx serialization is not a fixed point")
+ }
+
+ var afterReuse bytes.Buffer
+ if err := tx.Serialize(&afterReuse); err != nil {
+ t.Fatalf("serialize first tx after re-decode: %v", err)
+ }
+ if !bytes.Equal(first.Bytes(), afterReuse.Bytes()) {
+ t.Fatal("first tx changed after arena reuse")
+ }
+ })
+}
+
+// FuzzBlockDecode feeds arbitrary bytes to the block decoder, which shares
+// a single decode arena across every transaction in the block, rewinding it
+// between transactions. Successful decodes must round-trip byte for byte, and
+// the first decoded block must remain stable after the second decode.
+func FuzzBlockDecode(f *testing.F) {
+ var seed bytes.Buffer
+ if err := blockOne.Serialize(&seed); err != nil {
+ f.Fatal(err)
+ }
+ f.Add(bytes.Clone(seed.Bytes()))
+
+ f.Fuzz(func(t *testing.T, data []byte) {
+ var block MsgBlock
+ if err := block.Deserialize(bytes.NewReader(data)); err != nil {
+ return
+ }
+
+ var first bytes.Buffer
+ if err := block.Serialize(&first); err != nil {
+ t.Fatalf("serialize after decode: %v", err)
+ }
+
+ var again MsgBlock
+ err := again.Deserialize(bytes.NewReader(first.Bytes()))
+ if err != nil {
+ t.Fatalf("re-decode of serialized block: %v", err)
+ }
+
+ var second bytes.Buffer
+ if err := again.Serialize(&second); err != nil {
+ t.Fatalf("serialize after re-decode: %v", err)
+ }
+ if !bytes.Equal(first.Bytes(), second.Bytes()) {
+ t.Fatal("block serialization is not a fixed point")
+ }
+
+ var afterReuse bytes.Buffer
+ if err := block.Serialize(&afterReuse); err != nil {
+ t.Fatalf("serialize first block after re-decode: %v", err)
+ }
+ if !bytes.Equal(first.Bytes(), afterReuse.Bytes()) {
+ t.Fatal("first block changed after arena reuse")
+ }
+ })
+}
### wire/go.mod
@@ -7,6 +7,7 @@ require (
github.com/davecgh/go-spew v1.1.1
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.40.0
+ pgregory.net/rapid v1.3.0
)
require (
### wire/go.sum
@@ -14,3 +14,5 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc=
+pgregory.net/rapid v1.3.0/go.mod h1:dPlE4OBBxgXPqkP79flB6sJL1dx5azpI7HQ9MY9Z7uk=
### wire/message.go
@@ -672,10 +672,10 @@ func readMessageWithEncodingNInternal(r io.Reader, pver uint32,
return totalBytes, nil, nil, messageError("ReadMessage", str)
}
- // Read payload.
- payload := make([]byte, hdr.length)
- n, err := io.ReadFull(r, payload)
- totalBytes += n
+ // Read the payload as bytes arrive so a header cannot reserve the full
+ // claimed length on its own.
+ payload, err := readBytes(r, uint64(hdr.length))
+ totalBytes += len(payload)
if err != nil {
return totalBytes, nil, nil, err
}
### wire/msgaddr.go
@@ -69,6 +69,11 @@ func (msg *MsgAddr) BtcDecode(r io.Reader, pver uint32, enc MessageEncoding) err
"[count %v, max %v]", count, MaxAddrPerMsg)
return messageError("MsgAddr.BtcDecode", str)
}
+ if err := validateElementCount(
+ r, count, minNetAddressPayload,
+ ); err != nil {
+ return err
+ }
addrList := make([]NetAddress, count)
msg.AddrList = make([]*NetAddress, 0, count)
### wire/msgaddrv2.go
@@ -32,6 +32,11 @@ func (m *MsgAddrV2) BtcDecode(r io.Reader, pver uint32,
" max %v]", count, MaxV2AddrPerMsg)
return messageError("MsgAddrV2.BtcDecode", str)
}
+ if err := validateElementCount(
+ r, count, minNetAddressV2Payload,
+ ); err != nil {
+ return err
+ }
addrList := make([]NetAddressV2, count)
m.AddrList = make([]*NetAddressV2, 0, count)
### wire/msgblock.go
@@ -97,14 +97,23 @@ func (msg *MsgBlock) BtcDecode(r io.Reader, pver uint32, enc MessageEncoding) er
"[count %d, max %d]", txCount, maxTxPerBlock)
return messageError("MsgBlock.BtcDecode", str)
}
+ if err := validateElementCount(
+ r, txCount, minTxPayload,
+ ); err != nil {
+ return err
+ }
- scriptBuf := scriptPool.Borrow()
- defer scriptPool.Return(scriptBuf)
+ // A single arena is shared by every transaction in the block. Each
+ // transaction's btcDecode rewinds it, which is safe because the
+ // scripts are copied into their final exactly-sized buffer before the
+ // next transaction is decoded.
+ ar := borrowScriptArena()
+ defer ar.release()
msg.Transactions = make([]*MsgTx, 0, txCount)
for i := uint64(0); i < txCount; i++ {
tx := MsgTx{}
- err := tx.btcDecode(r, pver, enc, buf, scriptBuf[:])
+ err := tx.btcDecode(r, pver, enc, buf, ar)
if err != nil {
return err
}
@@ -173,9 +182,14 @@ func (msg *MsgBlock) DeserializeTxLoc(r *bytes.Buffer) ([]TxLoc, error) {
"[count %d, max %d]", txCount, maxTxPerBlock)
return nil, messageError("MsgBlock.DeserializeTxLoc", str)
}
+ if err := validateElementCount(
+ r, txCount, minTxPayload,
+ ); err != nil {
+ return nil, err
+ }
- scriptBuf := scriptPool.Borrow()
- defer scriptPool.Return(scriptBuf)
+ ar := borrowScriptArena()
+ defer ar.release()
// Deserialize each transaction while keeping track of its location
// within the byte stream.
@@ -184,7 +198,7 @@ func (msg *MsgBlock) DeserializeTxLoc(r *bytes.Buffer) ([]TxLoc, error) {
for i := uint64(0); i < txCount; i++ {
txLocs[i].TxStart = fullLen - r.Len()
tx := MsgTx{}
- err := tx.btcDecode(r, 0, WitnessEncoding, buf, scriptBuf[:])
+ err := tx.btcDecode(r, 0, WitnessEncoding, buf, ar)
if err != nil {
return nil, err
}
### wire/msgcfcheckpt.go
@@ -83,25 +83,52 @@ func (msg *MsgCFCheckpt) BtcDecode(r io.Reader, pver uint32, _ MessageEncoding)
if count > maxCFHeadersLen {
return ErrInsaneCFHeaderCount
}
+ preallocate, err := canPreallocateElements(
+ r, count, chainhash.HashSize,
+ )
+ if err != nil {
+ return err
+ }
if count == 0 {
msg.FilterHeaders = make([]*chainhash.Hash, 0)
return nil
}
- // Optimize memory allocation by creating a single backing array for
- // all hashes. This reduces GC pressure and improves cache locality.
- hashes := make([]chainhash.Hash, count)
- msg.FilterHeaders = make([]*chainhash.Hash, count)
+ // Optimize memory allocation by creating a single backing array for all
+ // hashes. Buffered readers reserve the exact count, while streaming
+ // readers grow the array as hashes arrive.
+ var hashes []chainhash.Hash
+ if preallocate {
+ hashes = make([]chainhash.Hash, count)
+ msg.FilterHeaders = make([]*chainhash.Hash, count)
+ } else {
+ hashes = make(
+ []chainhash.Hash, 0,
+ min(count, defaultStreamingElementCap),
+ )
+ msg.FilterHeaders = nil
+ }
// Now we'll read all the hashes directly into the backing array we've
// created above. We'll then point the underlying filter header hashes
// into this backing array.
for i := uint64(0); i < count; i++ {
+ if !preallocate {
+ hashes = append(hashes, chainhash.Hash{})
+ }
if _, err := io.ReadFull(r, hashes[i][:]); err != nil {
return err
}
- msg.FilterHeaders[i] = &hashes[i]
+ if preallocate {
+ msg.FilterHeaders[i] = &hashes[i]
+ }
+ }
+ if !preallocate {
+ msg.FilterHeaders = make([]*chainhash.Hash, len(hashes))
+ for i := range hashes {
+ msg.FilterHeaders[i] = &hashes[i]
+ }
}
return nil
### wire/msgcfheaders.go
@@ -80,6 +80,11 @@ func (msg *MsgCFHeaders) BtcDecode(r io.Reader, pver uint32, _ MessageEncoding)
MaxBlockHeadersPerMsg)
return messageError("MsgCFHeaders.BtcDecode", str)
}
+ if err := validateElementCount(
+ r, count, chainhash.HashSize,
+ ); err != nil {
+ return err
+ }
// Create a contiguous slice of hashes to deserialize into in order to
// reduce the number of allocations.
### wire/msggetblocks.go
@@ -70,6 +70,11 @@ func (msg *MsgGetBlocks) BtcDecode(r io.Reader, pver uint32, enc MessageEncoding
"[count %v, max %v]", count, MaxBlockLocatorsPerMsg)
return messageError("MsgGetBlocks.BtcDecode", str)
}
+ if err := validateElementCount(
+ r, count, chainhash.HashSize,
+ ); err != nil {
+ return err
+ }
// Create a contiguous slice of hashes to deserialize into in order to
// reduce the number of allocations.
### wire/msggetdata.go
@@ -51,6 +51,11 @@ func (msg *MsgGetData) BtcDecode(r io.Reader, pver uint32, enc MessageEncoding)
str := fmt.Sprintf("too many invvect in message [%v]", count)
return messageError("MsgGetData.BtcDecode", str)
}
+ if err := validateElementCount(
+ r, count, maxInvVectPayload,
+ ); err != nil {
+ return err
+ }
// Create a contiguous slice of inventory vectors to deserialize into in
// order to reduce the number of allocations.
### wire/msggetheaders.go
@@ -67,6 +67,11 @@ func (msg *MsgGetHeaders) BtcDecode(r io.Reader, pver uint32, enc MessageEncodin
"[count %v, max %v]", count, MaxBlockLocatorsPerMsg)
return messageError("MsgGetHeaders.BtcDecode", str)
}
+ if err := validateElementCount(
+ r, count, chainhash.HashSize,
+ ); err != nil {
+ return err
+ }
// Create a contiguous slice of hashes to deserialize into in order to
// reduce the number of allocations.
### wire/msgheaders.go
@@ -51,6 +51,11 @@ func (msg *MsgHeaders) BtcDecode(r io.Reader, pver uint32, enc MessageEncoding)
"[count %v, max %v]", count, MaxBlockHeadersPerMsg)
return messageError("MsgHeaders.BtcDecode", str)
}
+ if err := validateElementCount(
+ r, count, MaxBlockHeaderPayload+1,
+ ); err != nil {
+ return err
+ }
// Create a contiguous slice of headers to deserialize into in order to
// reduce the number of allocations.
### wire/msginv.go
@@ -59,6 +59,11 @@ func (msg *MsgInv) BtcDecode(r io.Reader, pver uint32, enc MessageEncoding) erro
str := fmt.Sprintf("too many invvect in message [%v]", count)
return messageError("MsgInv.BtcDecode", str)
}
+ if err := validateElementCount(
+ r, count, maxInvVectPayload,
+ ); err != nil {
+ return err
+ }
// Create a contiguous slice of inventory vectors to deserialize into in
// order to reduce the number of allocations.
### wire/msgmerkleblock.go
@@ -72,6 +72,11 @@ func (msg *MsgMerkleBlock) BtcDecode(r io.Reader, pver uint32, enc MessageEncodi
"[count %v, max %v]", count, maxTxPerBlock)
return messageError("MsgMerkleBlock.BtcDecode", str)
}
+ if err := validateElementCount(
+ r, count, chainhash.HashSize,
+ ); err != nil {
+ return err
+ }
// Create a contiguous slice of hashes to deserialize into in order to
// reduce the number of allocations.
### wire/msgnotfound.go
@@ -48,6 +48,11 @@ func (msg *MsgNotFound) BtcDecode(r io.Reader, pver uint32, enc MessageEncoding)
str := fmt.Sprintf("too many invvect in message [%v]", count)
return messageError("MsgNotFound.BtcDecode", str)
}
+ if err := validateElementCount(
+ r, count, maxInvVectPayload,
+ ); err != nil {
+ return err
+ }
// Create a contiguous slice of inventory vectors to deserialize into in
// order to reduce the number of allocations.
### wire/msgtx.go
@@ -81,21 +81,6 @@ const (
// payload + min output payload.
minTxPayload = 10
- // freeListMaxScriptSize is the size of each buffer in the free list
- // that is used for deserializing scripts from the wire before they are
- // concatenated into a single contiguous buffers. This value was chosen
- // because it is slightly more than twice the size of the vast majority
- // of all "standard" scripts. Larger scripts are still deserialized
- // properly as the free list will simply be bypassed for them.
- freeListMaxScriptSize = 512
-
- // freeListMaxItems is the number of buffers to keep in the free list
- // to use for script deserialization. This value allows up to 100
- // scripts per transaction being simultaneously deserialized by 125
- // peers. Thus, the peak usage of the free list is 12,500 * 512 =
- // 6,400,000 bytes.
- freeListMaxItems = 125
-
// maxWitnessItemsPerInput is the maximum number of witness items to
// be read for the witness data for a single TxIn. This number is
// derived using a possible lower bound for the encoding of a witness
@@ -155,57 +140,6 @@ const (
WitnessFlag TxFlag = 0x01
)
-const scriptSlabSize = 1 << 22
-
-type scriptSlab [scriptSlabSize]byte
-
-// scriptFreeList defines a free list of byte slices (up to the maximum number
-// defined by the freeListMaxItems constant) that have a cap according to the
-// freeListMaxScriptSize constant. It is used to provide temporary buffers for
-// deserializing scripts in order to greatly reduce the number of allocations
-// required.
-//
-// The caller can obtain a buffer from the free list by calling the Borrow
-// function and should return it via the Return function when done using it.
-type scriptFreeList chan *scriptSlab
-
-// Borrow returns a byte slice from the free list with a length according the
-// provided size. A new buffer is allocated if there are any items available.
-//
-// When the size is larger than the max size allowed for items on the free list
-// a new buffer of the appropriate size is allocated and returned. It is safe
-// to attempt to return said buffer via the Return function as it will be
-// ignored and allowed to go the garbage collector.
-func (c scriptFreeList) Borrow() *scriptSlab {
- var buf *scriptSlab
- select {
- case buf = <-c:
- default:
- buf = new(scriptSlab)
- }
- return buf
-}
-
-// Return puts the provided byte slice back on the free list when it has a cap
-// of the expected length. The buffer is expected to have been obtained via
-// the Borrow function. Any slices that are not of the appropriate size, such
-// as those whose size is greater than the largest allowed free list item size
-// are simply ignored so they can go to the garbage collector.
-func (c scriptFreeList) Return(buf *scriptSlab) {
- // Return the buffer to the free list when it's not full. Otherwise let
- // it be garbage collected.
- select {
- case c <- buf:
- default:
- // Let it go to the garbage collector.
- }
-}
-
-// Create the concurrent safe free list to use for script deserialization. As
-// previously described, this free list is maintained to significantly reduce
-// the number of allocations.
-var scriptPool = make(scriptFreeList, freeListMaxItems)
-
// OutPoint defines a bitcoin data type that is used to track previous
// transaction outputs.
type OutPoint struct {
@@ -480,19 +414,32 @@ func (msg *MsgTx) Copy() *MsgTx {
// This is part of the Message interface implementation.
// See Deserialize for decoding transactions stored to disk, such as in a
// database, as opposed to decoding transactions from the wire.
+// If decoding fails, TxIn and TxOut are cleared. Other fields may be partially
+// populated, so the receiver's contents must not be used unless a subsequent
+// decode succeeds.
func (msg *MsgTx) BtcDecode(r io.Reader, pver uint32, enc MessageEncoding) error {
buf := binarySerializer.Borrow()
defer binarySerializer.Return(buf)
- sbuf := scriptPool.Borrow()
- defer scriptPool.Return(sbuf)
+ ar := borrowScriptArena()
+ defer ar.release()
- err := msg.btcDecode(r, pver, enc, buf, sbuf[:])
+ err := msg.btcDecode(r, pver, enc, buf, ar)
+ if err != nil {
+ // Drop staged scripts before their arena is returned to the pool.
+ msg.TxIn = nil
+ msg.TxOut = nil
+ }
return err
}
func (msg *MsgTx) btcDecode(r io.Reader, pver uint32, enc MessageEncoding,
- buf, sbuf []byte) error {
+ buf []byte, ar *scriptArena) error {
+
+ // Any script data staged by a previous transaction decoded with this
+ // arena has already been copied into its final exactly-sized buffer,
+ // so the arena memory can be reused from the start.
+ ar.rewind()
if _, err := io.ReadFull(r, buf[:4]); err != nil {
return err
@@ -538,22 +485,48 @@ func (msg *MsgTx) btcDecode(r io.Reader, pver uint32, enc MessageEncoding,
maxTxInPerMessage)
return messageError("MsgTx.BtcDecode", str)
}
+ preallocateInputs, err := canPreallocateElements(
+ r, count, minTxInPayload,
+ )
+ if err != nil {
+ return err
+ }
// Deserialize the inputs.
var totalScriptSize uint64
- txIns := make([]TxIn, count)
- msg.TxIn = make([]*TxIn, count)
+ var txIns []TxIn
+ if preallocateInputs {
+ txIns = make([]TxIn, count)
+ msg.TxIn = make([]*TxIn, count)
+ } else {
+ txIns = make(
+ []TxIn, 0, min(count, defaultStreamingElementCap),
+ )
+ msg.TxIn = nil
+ }
for i := uint64(0); i < count; i++ {
- // The pointer is set now in case a script buffer is borrowed
- // and needs to be returned to the pool on error.
+ if !preallocateInputs {
+ txIns = append(txIns, TxIn{})
+ }
+
+ // Buffered inputs can point into their final backing array before
+ // the decode call populates it. Streaming inputs are wired up once
+ // their backing array stops growing.
ti := &txIns[i]
- msg.TxIn[i] = ti
- err = readTxInBuf(r, pver, msg.Version, ti, buf, sbuf)
+ if preallocateInputs {
+ msg.TxIn[i] = ti
+ }
+ err = readTxInBuf(r, pver, msg.Version, ti, buf, ar)
if err != nil {
return err
}
totalScriptSize += uint64(len(ti.SignatureScript))
- sbuf = sbuf[len(ti.SignatureScript):]
+ }
+ if !preallocateInputs {
+ msg.TxIn = make([]*TxIn, len(txIns))
+ for i := range txIns {
+ msg.TxIn[i] = &txIns[i]
+ }
}
count, err = ReadVarIntBuf(r, pver, buf)
@@ -570,21 +543,47 @@ func (msg *MsgTx) btcDecode(r io.Reader, pver uint32, enc MessageEncoding,
maxTxOutPerMessage)
return messageError("MsgTx.BtcDecode", str)
}
+ preallocateOutputs, err := canPreallocateElements(
+ r, count, MinTxOutPayload,
+ )
+ if err != nil {
+ return err
+ }
// Deserialize the outputs.
- txOuts := make([]TxOut, count)
- msg.TxOut = make([]*TxOut, count)
+ var txOuts []TxOut
+ if preallocateOutputs {
+ txOuts = make([]TxOut, count)
+ msg.TxOut = make([]*TxOut, count)
+ } else {
+ txOuts = make(
+ []TxOut, 0, min(count, defaultStreamingElementCap),
+ )
+ msg.TxOut = nil
+ }
for i := uint64(0); i < count; i++ {
- // The pointer is set now in case a script buffer is borrowed
- // and needs to be returned to the pool on error.
+ if !preallocateOutputs {
+ txOuts = append(txOuts, TxOut{})
+ }
+
+ // Buffered outputs can point into their final backing array before
+ // the decode call populates it. Streaming outputs are wired up once
+ // their backing array stops growing.
to := &txOuts[i]
- msg.TxOut[i] = to
- err = readTxOutBuf(r, pver, msg.Version, to, buf, sbuf)
+ if preallocateOutputs {
+ msg.TxOut[i] = to
+ }
+ err = readTxOutBuf(r, pver, msg.Version, to, buf, ar)
if err != nil {
return err
}
totalScriptSize += uint64(len(to.PkScript))
- sbuf = sbuf[len(to.PkScript):]
+ }
+ if !preallocateOutputs {
+ msg.TxOut = make([]*TxOut, len(txOuts))
+ for i := range txOuts {
+ msg.TxOut[i] = &txOuts[i]
+ }
}
// If the transaction's flag byte isn't 0x00 at this point, then one or
@@ -607,20 +606,37 @@ func (msg *MsgTx) btcDecode(r io.Reader, pver uint32, enc MessageEncoding,
witCount, maxWitnessItemsPerInput)
return messageError("MsgTx.BtcDecode", str)
}
+ preallocateWitness, err := canPreallocateElements(
+ r, witCount, 1,
+ )
+ if err != nil {
+ return err
+ }
// Then for witCount number of stack items, each item
// has a varint length prefix, followed by the witness
// item itself.
- txin.Witness = make([][]byte, witCount)
+ if preallocateWitness {
+ txin.Witness = make([][]byte, witCount)
+ } else {
+ txin.Witness = make(
+ [][]byte, 0,
+ min(witCount, defaultStreamingElementCap),
+ )
+ }
for j := uint64(0); j < witCount; j++ {
- txin.Witness[j], err = readScriptBuf(
- r, pver, buf, sbuf, "script witness item",
+ item, err := readScriptBuf(
+ r, pver, buf, ar, "script witness item",
)
if err != nil {
return err
}
- totalScriptSize += uint64(len(txin.Witness[j]))
- sbuf = sbuf[len(txin.Witness[j]):]
+ if preallocateWitness {
+ txin.Witness[j] = item
+ } else {
+ txin.Witness = append(txin.Witness, item)
+ }
+ totalScriptSize += uint64(len(item))
}
}
@@ -637,19 +653,18 @@ func (msg *MsgTx) btcDecode(r io.Reader, pver uint32, enc MessageEncoding,
msg.LockTime = littleEndian.Uint32(buf[:4])
// Create a single allocation to house all of the scripts and set each
- // input signature script and output public key script to the
- // appropriate subslice of the overall contiguous buffer. Then, return
- // each individual script buffer back to the pool so they can be reused
- // for future deserializations. This is done because it significantly
- // reduces the number of allocations the garbage collector needs to
- // track, which in turn improves performance and drastically reduces the
- // amount of runtime overhead that would otherwise be needed to keep
- // track of millions of small allocations.
+ // input signature script, witness item, and output public key script
+ // to the appropriate subslice of the overall contiguous buffer. This
+ // copies the scripts out of the transient decode arena into memory
+ // the transaction owns, which is what makes rewinding the arena for
+ // the next transaction safe. A single backing allocation also
+ // significantly reduces the number of allocations the garbage
+ // collector needs to track, which in turn improves performance and
+ // drastically reduces the amount of runtime overhead that would
+ // otherwise be needed to keep track of millions of small allocations.
//
- // NOTE: It is no longer valid to call the returnScriptBuffers closure
- // after these blocks of code run because it is already done and the
- // scripts in the transaction inputs and outputs no longer point to the
- // buffers.
+ // NOTE: After these blocks of code run, no script in the transaction
+ // points into the arena any longer.
var offset uint64
scripts := make([]byte, totalScriptSize)
for i := 0; i < len(msg.TxIn); i++ {
@@ -956,11 +971,7 @@ func NewMsgTx(version int32) *MsgTx {
// readOutPointBuf reads the next sequence of bytes from r as an OutPoint.
//
-// If b is non-nil, the provided buffer will be used for serializing small
-// values. Otherwise a buffer will be drawn from the binarySerializer's pool
-// and return when the method finishes.
-//
-// NOTE: b MUST either be nil or at least an 8-byte slice.
+// NOTE: buf MUST be at least an 8-byte slice.
func readOutPointBuf(r io.Reader, pver uint32, version int32, op *OutPoint,
buf []byte) error {
@@ -990,11 +1001,7 @@ func WriteOutPoint(w io.Writer, pver uint32, version int32, op *OutPoint) error
// writeOutPointBuf encodes op to the bitcoin protocol encoding for an OutPoint
// to w.
//
-// If b is non-nil, the provided buffer will be used for serializing small
-// values. Otherwise a buffer will be drawn from the binarySerializer's pool
-// and return when the method finishes.
-//
-// NOTE: b MUST either be nil or at least an 8-byte slice.
+// NOTE: buf MUST be at least an 8-byte slice.
func writeOutPointBuf(w io.Writer, pver uint32, version int32, op *OutPoint,
buf []byte) error {
@@ -1008,20 +1015,18 @@ func writeOutPointBuf(w io.Writer, pver uint32, version int32, op *OutPoint,
return err
}
-// readScript reads a variable length byte array that represents a transaction
-// script. It is encoded as a varInt containing the length of the array
-// followed by the bytes themselves. An error is returned if the length is
-// greater than the passed maxAllowed parameter which helps protect against
-// memory exhaustion attacks and forced panics through malformed messages. The
-// fieldName parameter is only used for the error message so it provides more
-// context in the error.
-//
-// If b is non-nil, the provided buffer will be used for serializing small
-// values. Otherwise a buffer will be drawn from the binarySerializer's pool
-// and return when the method finishes.
+// readScriptBuf reads a variable length byte array that represents a
+// transaction script. It is encoded as a varInt containing the length of the
+// array followed by the bytes themselves. The script bytes are staged in the
+// provided arena, so the returned slice is only valid until the arena is
+// rewound or released. An error is returned if the length is greater than
+// the max allowed script size which helps protect against memory exhaustion
+// attacks and forced panics through malformed messages. The fieldName
+// parameter is only used for the error message so it provides more context in
+// the error.
//
-// NOTE: b MUST either be nil or at least an 8-byte slice.
-func readScriptBuf(r io.Reader, pver uint32, buf, s []byte,
+// NOTE: buf MUST be at least an 8-byte slice.
+func readScriptBuf(r io.Reader, pver uint32, buf []byte, ar *scriptArena,
fieldName string) ([]byte, error) {
count, err := ReadVarIntBuf(r, pver, buf)
@@ -1039,40 +1044,38 @@ func readScriptBuf(r io.Reader, pver uint32, buf, s []byte,
return nil, messageError("readScript", str)
}
- // Ensure the claimed script length fits in the remaining
- // decode slab.
- if count > uint64(len(s)) {
+ // Stage the script in the decode arena. This fails if the total
+ // script data staged for the transaction being decoded exceeds the
+ // arena capacity, which no valid transaction can reach.
+ s, err := ar.alloc(int(count))
+ if err != nil {
str := fmt.Sprintf("%s exceeds remaining buffer "+
"capacity [count %d, remaining %d]",
- fieldName, count, len(s))
+ fieldName, count, ar.remaining())
return nil, messageError("readScript", str)
}
- _, err = io.ReadFull(r, s[:count])
+ _, err = io.ReadFull(r, s)
if err != nil {
return nil, err
}
- return s[:count], nil
+ return s, nil
}
// readTxInBuf reads the next sequence of bytes from r as a transaction input
// (TxIn).
//
-// If b is non-nil, the provided buffer will be used for serializing small
-// values. Otherwise a buffer will be drawn from the binarySerializer's pool
-// and return when the method finishes.
-//
-// NOTE: b MUST either be nil or at least an 8-byte slice.
+// NOTE: buf MUST be at least an 8-byte slice.
func readTxInBuf(r io.Reader, pver uint32, version int32, ti *TxIn,
- buf, s []byte) error {
+ buf []byte, ar *scriptArena) error {
err := readOutPointBuf(r, pver, version, &ti.PreviousOutPoint, buf)
if err != nil {
return err
}
ti.SignatureScript, err = readScriptBuf(
- r, pver, buf, s, "transaction input signature script",
+ r, pver, buf, ar, "transaction input signature script",
)
if err != nil {
return err
@@ -1088,9 +1091,8 @@ func readTxInBuf(r io.Reader, pver uint32, version int32, ti *TxIn,
}
// writeTxInBuf encodes ti to the bitcoin protocol encoding for a transaction
-// input (TxIn) to w. If b is non-nil, the provided buffer will be used for
-// serializing small values. Otherwise a buffer will be drawn from the
-// binarySerializer's pool and return when the method finishes.
+// input (TxIn) to w. The buf parameter is the scratch buffer used for
+// serializing small values.
func writeTxInBuf(w io.Writer, pver uint32, version int32, ti *TxIn,
buf []byte) error {
@@ -1113,21 +1115,32 @@ func writeTxInBuf(w io.Writer, pver uint32, version int32, ti *TxIn,
// ReadTxOut reads the next sequence of bytes from r as a transaction output
// (TxOut).
func ReadTxOut(r io.Reader, pver uint32, version int32, to *TxOut) error {
- var s scriptSlab
-
buf := binarySerializer.Borrow()
defer binarySerializer.Return(buf)
- err := readTxOutBuf(r, pver, version, to, buf, s[:])
- return err
+ ar := borrowScriptArena()
+ defer ar.release()
+
+ err := readTxOutBuf(r, pver, version, to, buf, ar)
+ if err != nil {
+ return err
+ }
+
+ // The script is staged in the arena, which is recycled once this
+ // function returns, so copy it into an exactly-sized allocation the
+ // returned output owns.
+ pkScript := make([]byte, len(to.PkScript))
+ copy(pkScript, to.PkScript)
+ to.PkScript = pkScript
+
+ return nil
}
// readTxOutBuf reads the next sequence of bytes from r as a transaction output
-// (TxOut). If b is non-nil, the provided buffer will be used for serializing
-// small values. Otherwise a buffer will be drawn from the binarySerializer's
-// pool and return when the method finishes.
+// (TxOut). The buf parameter is the scratch buffer used for serializing
+// small values.
func readTxOutBuf(r io.Reader, pver uint32, version int32, to *TxOut,
- buf, s []byte) error {
+ buf []byte, ar *scriptArena) error {
_, err := io.ReadFull(r, buf)
if err != nil {
@@ -1136,7 +1149,7 @@ func readTxOutBuf(r io.Reader, pver uint32, version int32, to *TxOut,
to.Value = int64(littleEndian.Uint64(buf))
to.PkScript, err = readScriptBuf(
- r, pver, buf, s, "transaction output public key script",
+ r, pver, buf, ar, "transaction output public key script",
)
return err
}
@@ -1155,9 +1168,8 @@ func WriteTxOut(w io.Writer, pver uint32, version int32, to *TxOut) error {
}
// WriteTxOutBuf encodes to into the bitcoin protocol encoding for a transaction
-// output (TxOut) to w. If b is non-nil, the provided buffer will be used for
-// serializing small values. Otherwise a buffer will be drawn from the
-// binarySerializer's pool and return when the method finishes.
+// output (TxOut) to w. The buf parameter is the scratch buffer used for
+// serializing small values.
//
// NOTE: This function is exported in order to allow txscript to compute the
// new sighashes for witness transactions (BIP0143).
@@ -1174,9 +1186,8 @@ func WriteTxOutBuf(w io.Writer, pver uint32, version int32, to *TxOut,
}
// writeTxWitnessBuf encodes the bitcoin protocol encoding for a transaction
-// input's witness into to w. If b is non-nil, the provided buffer will be used
-// for serializing small values. Otherwise a buffer will be drawn from the
-// binarySerializer's pool and return when the method finishes.
+// input's witness into to w. The buf parameter is the scratch buffer used
+// for serializing small values.
func writeTxWitnessBuf(w io.Writer, pver uint32, version int32, wit [][]byte,
buf []byte) error {
### wire/msgtx_test.go
@@ -402,6 +402,52 @@ func TestTxWire(t *testing.T) {
}
}
+// TestTxDecodeErrorClearsScripts checks that failed public decodes drop all
+// script references and allow a later successful decode into the same receiver.
+func TestTxDecodeErrorClearsScripts(t *testing.T) {
+ for _, enc := range []MessageEncoding{BaseEncoding, WitnessEncoding} {
+ for _, buffered := range []bool{false, true} {
+ name := fmt.Sprintf("encoding=%d/buffered=%t", enc, buffered)
+ t.Run(name, func(t *testing.T) {
+ tx := NewMsgTx(2)
+ var witness TxWitness
+ if enc == WitnessEncoding {
+ witness = TxWitness{{0x01, 0x02}, {0x03}}
+ }
+ tx.AddTxIn(NewTxIn(
+ &OutPoint{}, []byte{0x04, 0x05}, witness,
+ ))
+ tx.AddTxOut(NewTxOut(1, []byte{0x06, 0x07}))
+ tx.LockTime = 123
+
+ var serialized bytes.Buffer
+ require.NoError(t, tx.BtcEncode(&serialized, 0, enc))
+ data := serialized.Bytes()
+ reader := func(data []byte) io.Reader {
+ r := bytes.NewReader(data)
+ if buffered {
+ return r
+ }
+ return struct{ io.Reader }{r}
+ }
+
+ // Include early errors on a populated receiver as well
+ // as errors after scripts and witnesses have been staged.
+ for end := 0; end < len(data); end++ {
+ decoded := tx.Copy()
+ err := decoded.BtcDecode(reader(data[:end]), 0, enc)
+ require.Error(t, err, "prefix length %d", end)
+ require.Nil(t, decoded.TxIn, "prefix length %d", end)
+ require.Nil(t, decoded.TxOut, "prefix length %d", end)
+
+ require.NoError(t, decoded.BtcDecode(reader(data), 0, enc))
+ require.Equal(t, tx, decoded)
+ }
+ })
+ }
+ }
+}
+
// TestTxWireErrors performs negative tests against wire encode and decode
// of MsgTx to confirm error paths work correctly.
func TestTxWireErrors(t *testing.T) {
@@ -1168,17 +1214,17 @@ var multiWitnessTxEncodedNonZeroFlag = []byte{
var multiWitnessTxPkScriptLocs = []int{58}
// TestTxWitnessOverflowPanic ensures that decoding a witness tx where
-// cumulative witness item lengths exceed the script slab capacity
+// cumulative witness item lengths exceed the script arena capacity
// returns a decode error instead of panicking on an out-of-bounds
// slice.
func TestTxWitnessOverflowPanic(t *testing.T) {
// Build a minimal witness tx with one input, zero outputs,
// and two witness items whose combined claimed lengths
- // exceed the scriptSlabSize (4 MiB) decode buffer.
+ // exceed the scriptArenaMaxAlloc (4 MiB) decode limit.
//
- // Item 1: 3 000 000 bytes (fits in slab, < maxWitnessItemSize)
+ // Item 1: 3 000 000 bytes (fits in arena, < maxWitnessItemSize)
// Item 2: 2 000 000 bytes (passes maxWitnessItemSize but
- // overflows remaining slab capacity of ~1.19 MiB)
+ // overflows remaining arena capacity of ~1.19 MiB)
const (
firstLen = 3_000_000
secondLen = 2_000_000
### wire/netaddress.go
@@ -10,11 +10,17 @@ import (
"time"
)
+const (
+ // minNetAddressPayload is the encoded size of an address without its
+ // optional timestamp.
+ minNetAddressPayload = 26
+)
+
// maxNetAddressPayload returns the max payload size for a bitcoin NetAddress
// based on the protocol version.
func maxNetAddressPayload(pver uint32) uint32 {
// Services 8 bytes + ip 16 bytes + port 2 bytes.
- plen := uint32(26)
+ plen := uint32(minNetAddressPayload)
// NetAddressTimeVersion added a timestamp field.
if pver >= NetAddressTimeVersion {
### wire/netaddressv2.go
@@ -14,6 +14,11 @@ import (
)
const (
+ // minNetAddressV2Payload is the minimum number of bytes needed to
+ // encode an address. An unknown network can carry an empty address, but
+ // still includes the timestamp, services, network ID, length, and port.
+ minNetAddressV2Payload = 9
+
// maxAddrV2Size is the maximum size an address may be in the addrv2
// message.
maxAddrV2Size = 512Why this scored 61/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.