Files
lightwalletd/common/common.go
DragonX Developers 4e7a1c0f9b rpc: bound every dragonxd call with a timeout, and stop serialising them
lightwalletd used github.com/btcsuite/btcd/rpcclient for exactly one
method, RawRequest, and that package has two defects that together
produce the failure seen on the primary node:

  1. It has no timeout and no way to set one. The http.Client is built
     in an unexported newHTTPClient() and ConnConfig exposes no Timeout
     field, so a call can hang forever. Calls were observed running past
     five minutes against a healthy node that answered the same query
     from dragonx-cli in 2ms.

  2. In HTTP POST mode it runs ONE sendPostHandler goroutine which calls
     handleSendPostMessage synchronously, so the whole process has at
     most one RPC in flight. One stuck call therefore blocks the block
     ingestor, the mempool monitor and every user-facing gRPC handler at
     once.

These compound: a timeout alone would not have been enough, because
bounding the caller's wait still leaves the shared goroutine stuck
inside http.Client.Do with everything queued behind it. Only a timeout
on the HTTP client aborts the in-flight request, and only dropping the
shared goroutine lets independent callers proceed.

Patching the vendored copy is not an option here: go.mod declares
go 1.12, so automatic vendor mode (go >= 1.14) is off and the committed
vendor/ tree is silently ignored in favour of the module cache. It is
also stale -- vendor/modules.txt disagrees with go.mod on btcd,
protobuf, logrus, sqlite3 and six other modules, so -mod=vendor cannot
build at all, and `go mod vendor` would discard any patch.

Replace it with package zrpc: a ~150-line JSON-RPC client that is safe
for concurrent use and takes a timeout. The request envelope, ID
sequence, basic auth and error semantics are deliberately identical.
RPCError.Error() still renders as "<code>: <message>" because callers
recover the numeric code from the string -- common.GetSaplingInfo
checks for -8 via strings.SplitN(err.Error(), ":", 2) -- and the
non-JSON body path still reports `status code: %d, response: %q`.

Default timeout 120s, tunable with -rpc-timeout (0 disables). The bound
is set by the slowest legitimate call: coinsupply measured 48s against
a cold UTXO set, 3s once cached, so a tighter timeout would turn a
slow-but-working call into a hard failure.

Also drops rpcclient's Close=true, which opened a fresh TCP connection
per request and left hundreds of sockets in TIME_WAIT on a busy node;
idle connections are now reused and capped.

Verified against a live dragonxd: getblockchaininfo returns chain=main;
a bad height yields exactly "-8: Block height out of range" and parses
back to -8; a 1ns timeout aborts in 54us instead of hanging; and the
built daemon serves GetLightdInfo at the current height with no errors.
The pre-existing parser TestCompactBlocks failure is unrelated and
reproduces on the base commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FU87LdsJZiZkfq1eXubpeo
2026-08-26 11:44:52 -05:00

330 lines
8.7 KiB
Go

package common
import (
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"git.hush.is/hush/lightwalletd/parser"
"git.hush.is/hush/lightwalletd/walletrpc"
"git.hush.is/hush/lightwalletd/zrpc"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
func GetSaplingInfo(rpcClient *zrpc.Client) (int, int, string, string, int, int, int, error) {
result, rpcErr := rpcClient.RawRequest("getblockchaininfo", make([]json.RawMessage, 0))
var err error
var errCode int64
// For some reason, the error responses are not JSON
if rpcErr != nil {
errParts := strings.SplitN(rpcErr.Error(), ":", 2)
errCode, err = strconv.ParseInt(errParts[0], 10, 32)
//Check to see if we are requesting a height the dragonxd doesn't have yet
if err == nil && errCode == -8 {
return -1, -1, "", "", -1, -1, -1, nil
}
return -1, -1, "", "", -1, -1, -1, errors.Wrap(rpcErr, "error requesting block")
}
var f interface{}
err = json.Unmarshal(result, &f)
if err != nil {
return -1, -1, "", "", -1, -1, -1, errors.Wrap(err, "error reading JSON response")
}
// Assert the top-level object once, then read every field with the comma-ok
// form. These run on the block-ingestor goroutine, and nothing in this
// daemon or in grpc-go v1.24.0 recovers a panic, so one unexpected response
// shape would take the whole process down rather than fail one call.
fmap, ok := f.(map[string]interface{})
if !ok {
return -1, -1, "", "", -1, -1, -1, errors.New("getblockchaininfo: unexpected response shape")
}
chainName, _ := fmap["chain"].(string)
// DragonX has Sapling active from block 1 but sets NO_ACTIVATION_HEIGHT in
// chainparams, so dragonxd omits it from the upgrades map. Fall back to
// height 1 when the key is absent.
saplingHeight := float64(1)
if upgradesMap, ok := fmap["upgrades"].(map[string]interface{}); ok {
if saplingJSON, ok := upgradesMap["76b809bb"].(map[string]interface{}); ok {
if h, ok := saplingJSON["activationheight"].(float64); ok {
saplingHeight = h
}
}
}
blockHeight, _ := fmap["headers"].(float64)
difficulty, _ := fmap["difficulty"].(float64)
longestchain, _ := fmap["longestchain"].(float64)
notarized, _ := fmap["notarized"].(float64)
// DragonX always uses Sapling consensus rules but CurrentEpochBranchId()
// returns Sprout (0) for full nodes because the activation heights are
// set to NO_ACTIVATION_HEIGHT. Override to the correct Sapling branch ID.
branchID := "76b809bb"
consensus, ok := f.(map[string]interface{})["consensus"]
if ok {
if nextblock, ok := consensus.(map[string]interface{})["nextblock"].(string); ok && nextblock != "00000000" {
branchID = nextblock
}
}
return int(saplingHeight), int(blockHeight), chainName, branchID, int(difficulty), int(longestchain), int(notarized), nil
}
func GetCoinsupply(rpcClient *zrpc.Client) (string, string, int, int, int, int, error) {
result1, rpcErr := rpcClient.RawRequest("coinsupply", make([]json.RawMessage, 0))
var err error
var errCode int64
// For some reason, the error responses are not JSON
if rpcErr != nil {
errParts := strings.SplitN(rpcErr.Error(), ":", 2)
errCode, err = strconv.ParseInt(errParts[0], 10, 32)
//Check to see if we are requesting a height the dragonxd doesn't have yet
if err == nil && errCode == -8 {
return "", "", -1, -1, -1, -1, nil
}
return "", "", -1, -1, -1, -1, errors.Wrap(rpcErr, "error requesting coinsupply")
}
var f interface{}
err = json.Unmarshal(result1, &f)
if err != nil {
return "", "", -1, -1, -1, -1, errors.Wrap(err, "error reading JSON response")
}
coinsupply, ok := f.(map[string]interface{})
if !ok {
return "", "", -1, -1, -1, -1, errors.New("unexpected coinsupply response format")
}
getStringField := func(key string) string {
value, ok := coinsupply[key]
if !ok || value == nil {
return ""
}
if strValue, ok := value.(string); ok {
return strValue
}
return fmt.Sprintf("%v", value)
}
getNumberField := func(key string) int {
value, ok := coinsupply[key]
if !ok || value == nil {
return 0
}
number, ok := value.(float64)
if !ok {
return 0
}
return int(number)
}
result := getStringField("result")
coin := getStringField("coin")
height := getNumberField("height")
supply := getNumberField("supply")
zfunds := getNumberField("zfunds")
total := getNumberField("total")
return result, coin, height, supply, zfunds, total, nil
}
func getBlockFromRPC(rpcClient *zrpc.Client, height int) (*walletrpc.CompactBlock, error) {
params := make([]json.RawMessage, 2)
params[0] = json.RawMessage("\"" + strconv.Itoa(height) + "\"")
params[1] = json.RawMessage("0")
result, rpcErr := rpcClient.RawRequest("getblock", params)
var err error
var errCode int64
// For some reason, the error responses are not JSON
if rpcErr != nil {
errParts := strings.SplitN(rpcErr.Error(), ":", 2)
errCode, err = strconv.ParseInt(errParts[0], 10, 32)
//Check to see if we are requesting a height the dragonxd doesn't have yet
if err == nil && errCode == -8 {
return nil, nil
}
return nil, errors.Wrap(rpcErr, "error requesting block")
}
var blockDataHex string
err = json.Unmarshal(result, &blockDataHex)
if err != nil {
return nil, errors.Wrap(err, "error reading JSON response")
}
blockData, err := hex.DecodeString(blockDataHex)
if err != nil {
return nil, errors.Wrap(err, "error decoding getblock output")
}
block := parser.NewBlock()
rest, err := block.ParseFromSlice(blockData)
if err != nil {
return nil, errors.Wrap(err, "error parsing block")
}
if len(rest) != 0 {
return nil, errors.New("received overlong message")
}
return block.ToCompact(), nil
}
func BlockIngestor(rpcClient *zrpc.Client, cache *BlockCache, log *logrus.Entry,
stopChan chan bool, startHeight int) {
reorgCount := 0
height := startHeight
timeoutCount := 0
// Start listening for new blocks
for {
select {
case <-stopChan:
break
case <-time.After(15 * time.Second):
for {
if reorgCount > 0 {
height -= 10
}
if reorgCount > 10 {
log.Error("Reorg exceeded max of 100 blocks! Help!")
return
}
block, err := getBlockFromRPC(rpcClient, height)
if err != nil {
log.WithFields(logrus.Fields{
"height": height,
"error": err,
}).Warn("error with getblock")
timeoutCount++
if timeoutCount == 3 {
log.WithFields(logrus.Fields{
"timeouts": timeoutCount,
}).Warn("unable to issue RPC call to dragonxd node 3 times")
break
}
}
if block != nil {
if timeoutCount > 0 {
timeoutCount--
}
log.Info("Ingestor adding block to cache: ", height)
err, reorg := cache.Add(height, block)
if err != nil {
log.Error("Error adding block to cache: ", err)
continue
}
//check for reorgs once we have inital block hash from startup
if reorg {
reorgCount++
log.WithFields(logrus.Fields{
"height": height,
"hash": displayHash(block.Hash),
"phash": displayHash(block.PrevHash),
"reorg": reorgCount,
}).Warn("REORG")
} else {
reorgCount = 0
height++
}
} else {
break
}
}
}
}
}
func GetBlock(rpcClient *zrpc.Client, cache *BlockCache, height int) (*walletrpc.CompactBlock, error) {
// Don't serve blocks above the advertised (lag-adjusted) tip, so wallets can
// neither sync nor anchor shielded spends into the unstable reorg zone.
if !cache.HeightAllowed(height) {
return nil, errors.New(
fmt.Sprintf("Block %d is above the advertised tip (reorg-lag protection)", height))
}
// First, check the cache to see if we have the block
block := cache.Get(height)
if block != nil {
return block, nil
}
// If a block was not found, make sure user is requesting a historical block
if height > cache.GetLatestBlock() {
return nil, errors.New(
fmt.Sprintf(
"Block requested is newer than latest block. Requested: %d Latest: %d",
height, cache.GetLatestBlock()))
}
block, err := getBlockFromRPC(rpcClient, height)
if err != nil {
return nil, err
}
return block, nil
}
func GetBlockRange(rpcClient *zrpc.Client, cache *BlockCache,
blockOut chan<- walletrpc.CompactBlock, errOut chan<- error, start, end int) {
// Go over [start, end] inclusive
for i := start; i <= end; i++ {
block, err := GetBlock(rpcClient, cache, i)
if err != nil {
errOut <- err
return
}
if block == nil {
errOut <- errors.New(
fmt.Sprintf("Block %d was nil without error", i))
return
}
blockOut <- *block
}
errOut <- nil
}
func displayHash(hash []byte) string {
rhash := make([]byte, len(hash))
copy(rhash, hash)
// Reverse byte order
for i := 0; i < len(rhash)/2; i++ {
j := len(rhash) - 1 - i
rhash[i], rhash[j] = rhash[j], rhash[i]
}
return hex.EncodeToString(rhash)
}