What changed, and why it matters
This commit adds a new client method to request and decode a Bitcoin proof that a transaction was included in a block. It also adds a small helper that strips surrounding quote characters from a JSON string response instead of fully parsing it. There is no indication in the commit that this fixes a security bug; it appears to be a routine feature addition with a minor parsing optimization.
No immediate action required. If adopting this parsing shortcut elsewhere, review it against valid JSON edge cases (escaped quotes, whitespace, embedded backslashes) to avoid subtle decoding failures.
Security signals we found
New RPC client helper parses JSON string by trimming quotes rather than unmarshalling
Parsing shortcut could mishandle escaped characters or malformed JSON, but only affects client-side decoding of a trusted RPC response
No bounds checks or validation added around the trimmed payload length
Evidence from the diff
The change introduces GetTxOutProof and GetTxOutProofAsync in rpcclient/chain.go, wrapping the existing btcjson.NewGetTxOutProofCmd. The RPC returns a hex-encoded merkle block as a JSON string; the new code decodes it via wire.MsgMerkleBlock.BtcDecode over a hex.NewDecoder. To avoid json.Unmarshal, two helpers parseJSONString and parseJSONStringReader trim leading/trailing double-quote bytes. Tests and benchmarks compare the trim approach against json.Unmarshal. No server-side logic, consensus code, or authentication handling is modified.
Changed components
rpcclient/chain.gorpcclient/chain_test.goInspect captured patch +200 / −0
diff --git a/rpcclient/chain.go b/rpcclient/chain.go
index dcac9af..1721c92 100644
--- a/rpcclient/chain.go
+++ b/rpcclient/chain.go
@@ -9,6 +9,8 @@ import (
"bytes"
"encoding/hex"
"encoding/json"
+ "io"
+ "strings"
"github.com/btcsuite/btcd/btcjson"
"github.com/btcsuite/btcd/chaincfg/chainhash"
@@ -1102,6 +1104,49 @@ func (c *Client) GetTxOutSetInfo() (*btcjson.GetTxOutSetInfoResult, error) {
return c.GetTxOutSetInfoAsync().Receive()
}
+// FutureGetTxOutProofResult is a future promise to deliver the result of a
+// GetTxOutProofAsync RPC invocation (or an applicable error).
+type FutureGetTxOutProofResult chan *Response
+
+// Receive waits for the Response promised by the future and returns the
+// results of GetTxOutSetInfoAsync RPC invocation.
+func (r FutureGetTxOutProofResult) Receive() (*wire.MsgMerkleBlock, error) {
+ res, err := ReceiveFuture(r)
+ if err != nil {
+ return nil, err
+ }
+
+ var merkleBlock wire.MsgMerkleBlock
+ err = merkleBlock.BtcDecode(
+ hex.NewDecoder(parseJSONStringReader(res)),
+ wire.ProtocolVersion, wire.WitnessEncoding,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ return &merkleBlock, nil
+}
+
+// GetTxOutProofAsync returns an instance of a type that can be used to get
+// the result of the RPC at some future time by invoking the Receive function on
+// the returned instance.
+//
+// See GetTxOutProof for the blocking version and more details.
+func (c *Client) GetTxOutProofAsync(txIDs []string,
+ blockHash *string) FutureGetTxOutProofResult {
+
+ cmd := btcjson.NewGetTxOutProofCmd(txIDs, blockHash)
+ return c.SendCmd(cmd)
+}
+
+// GetTxOutProof returns the proof that a transaction was included in a block.
+func (c *Client) GetTxOutProof(txIDs []string,
+ blockHash *string) (*wire.MsgMerkleBlock, error) {
+
+ return c.GetTxOutProofAsync(txIDs, blockHash).Receive()
+}
+
// FutureRescanBlocksResult is a future promise to deliver the result of a
// RescanBlocksAsync RPC invocation (or an applicable error).
//
@@ -1455,3 +1500,24 @@ func (c *Client) ReconsiderBlockAsync(
func (c *Client) ReconsiderBlock(blockHash *chainhash.Hash) error {
return c.ReconsiderBlockAsync(blockHash).Receive()
}
+
+// parseJSONString parses a JSON byte slice as a JSON string by removing leading
+// and trailing double quotes.
+func parseJSONString(jsonWithQuotes []byte) string {
+ // The result is just a single hex string. So we don't need to unmarshal
+ // it into a string, replacing the quotes achieves the same result, just
+ // much faster and with fewer allocations.
+ return strings.TrimPrefix(
+ strings.TrimSuffix(string(jsonWithQuotes), "\""), "\"",
+ )
+}
+
+// parseJSONStringReader parses a JSON byte slice as a JSON string by removing
+// leading and trailing double quotes and returning it as a reader for further
+// processing.
+func parseJSONStringReader(jsonWithQuotes []byte) io.Reader {
+ quotes := []byte("\"")
+ return bytes.NewReader(bytes.TrimPrefix(
+ bytes.TrimSuffix(jsonWithQuotes, quotes), quotes,
+ ))
+}
diff --git a/rpcclient/chain_test.go b/rpcclient/chain_test.go
index 464506d..a946bf8 100644
--- a/rpcclient/chain_test.go
+++ b/rpcclient/chain_test.go
@@ -1,7 +1,11 @@
package rpcclient
import (
+ "bytes"
+ "encoding/hex"
+ "encoding/json"
"errors"
+ "io"
"net/http"
"net/http/httptest"
"strings"
@@ -9,7 +13,9 @@ import (
"testing"
"time"
+ "github.com/btcsuite/btcd/wire"
"github.com/gorilla/websocket"
+ "github.com/stretchr/testify/require"
)
var upgrader = websocket.Upgrader{}
@@ -254,6 +260,134 @@ func TestClientConnectedToWSServerRunner(t *testing.T) {
}
}
+// TestJSONStringParsing tests the old vs. the two new methods for parsing a
+// JSON string.
+func TestJSONStringParsing(t *testing.T) {
+ testCases := []string{
+ "\"\"",
+ "\"foo\"",
+ "\"5152dcbe2d94dd2c66008b1281157ff44156d146c83bf3a182ba5b32ce" +
+ "e79f5d\"",
+ "\"010000004860eb18bf1b1620e37e9490fc8a427514416fd75159ab8668" +
+ "8e9a8300000000d5fdcc541e25de1c7a5addedf24858b8bb665c" +
+ "9f36ef744ee42c316022c90f9bb0bc6649ffff001d08d2bd6101" +
+ "0100000001000000000000000000000000000000000000000000" +
+ "0000000000000000000000ffffffff0704ffff001d010bffffff" +
+ "ff0100f2052a010000004341047211a824f55b505228e4c3d519" +
+ "4c1fcfaa15a456abdf37f9b9d97a4040afc073dee6c89064984f" +
+ "03385237d92167c13e236446b417ab79a0fcae412ae3316b77ac" +
+ "00000000\"",
+ }
+
+ oldMethod := func(res []byte) (string, error) {
+ // Unmarshal result as a string.
+ var resultStr string
+ err := json.Unmarshal(res, &resultStr)
+ if err != nil {
+ return "", err
+ }
+
+ return resultStr, nil
+ }
+ newMethod := func(res []byte) (string, error) {
+ return parseJSONString(res), nil
+ }
+ newMethodReader := func(res []byte) (string, error) {
+ allBytes, err := io.ReadAll(parseJSONStringReader(res))
+ if err != nil {
+ return "", err
+ }
+
+ return string(allBytes), nil
+ }
+
+ for _, testCase := range testCases {
+ t.Run(testCase, func(t *testing.T) {
+ oldResult, err := oldMethod([]byte(testCase))
+ require.NoError(t, err)
+
+ newResult, err := newMethod([]byte(testCase))
+ require.NoError(t, err)
+
+ require.Equal(t, oldResult, newResult)
+
+ newResult2, err := newMethodReader([]byte(testCase))
+ require.NoError(t, err)
+
+ require.Equal(t, oldResult, newResult2)
+ })
+ }
+}
+
+// BenchmarkParseJSONString benchmarks the old vs. the two new methods for
+// parsing a JSON string.
+func BenchmarkParseJSONString(b *testing.B) {
+ // This is block 2 from mainnet.
+ payload := []byte(
+ "\"010000004860eb18bf1b1620e37e9490fc8a427514416fd75159ab8668" +
+ "8e9a8300000000d5fdcc541e25de1c7a5addedf24858b8bb665c" +
+ "9f36ef744ee42c316022c90f9bb0bc6649ffff001d08d2bd6101" +
+ "0100000001000000000000000000000000000000000000000000" +
+ "0000000000000000000000ffffffff0704ffff001d010bffffff" +
+ "ff0100f2052a010000004341047211a824f55b505228e4c3d519" +
+ "4c1fcfaa15a456abdf37f9b9d97a4040afc073dee6c89064984f" +
+ "03385237d92167c13e236446b417ab79a0fcae412ae3316b77ac" +
+ "00000000\"",
+ )
+
+ b.ResetTimer()
+ b.Run("unmarshal", func(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ var s string
+ if err := json.Unmarshal(payload, &s); err != nil {
+ b.Fatal(err)
+ }
+
+ serializedBlock, err := hex.DecodeString(s)
+ if err != nil {
+ b.Fatal(err)
+ }
+
+ var msgBlock wire.MsgBlock
+ err = msgBlock.Deserialize(bytes.NewReader(
+ serializedBlock,
+ ))
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+ })
+
+ b.Run("parseJSONString", func(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ s := parseJSONString(payload)
+
+ var msgBlock wire.MsgBlock
+ err := msgBlock.Deserialize(hex.NewDecoder(
+ strings.NewReader(s)),
+ )
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+ })
+
+ b.Run("parseJSONStringReader", func(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ r := parseJSONStringReader(payload)
+
+ var msgBlock wire.MsgBlock
+ err := msgBlock.Deserialize(hex.NewDecoder(r))
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+ })
+}
+
func makeClient(t *testing.T) (*Client, chan string, func()) {
serverReceivedChannel := make(chan string)
s := httptest.NewServer(http.HandlerFunc(makeUpgradeOnConnect(serverReceivedChannel)))
Why this scored 17/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.