Files
lightwalletd/frontend/service.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

438 lines
14 KiB
Go

package frontend
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"regexp"
"strconv"
"strings"
"time"
"git.hush.is/hush/lightwalletd/zrpc"
"github.com/sirupsen/logrus"
"git.hush.is/hush/lightwalletd/common"
"git.hush.is/hush/lightwalletd/walletrpc"
)
var (
ErrUnspecified = errors.New("request for unspecified identifier")
)
// the service type
type SqlStreamer struct {
cache *common.BlockCache
client *zrpc.Client
log *logrus.Entry
}
func NewSQLiteStreamer(client *zrpc.Client, cache *common.BlockCache, log *logrus.Entry) (walletrpc.CompactTxStreamerServer, error) {
return &SqlStreamer{cache, client, log}, nil
}
func (s *SqlStreamer) GracefulStop() error {
return nil
}
func (s *SqlStreamer) GetCache() *common.BlockCache {
return s.cache
}
func (s *SqlStreamer) GetLatestBlock(ctx context.Context, placeholder *walletrpc.ChainSpec) (*walletrpc.BlockID, error) {
// Advertise a tip that trails the real tip by the adaptive confirmation lag,
// so wallets anchor shielded spends at a settled height during reorg churn.
latestBlock := s.cache.AdvertisedLatestBlock()
s.log.WithFields(logrus.Fields{
"latestBlock": latestBlock,
"adaptiveLag": s.cache.CurrentLag(),
}).Info("GetLatestBlock called")
if latestBlock == -1 {
return nil, errors.New("Cache is empty. Server is probably not yet ready.")
}
// TODO: also return block hashes here
return &walletrpc.BlockID{Height: uint64(latestBlock)}, nil
}
func (s *SqlStreamer) GetAddressTxids(addressBlockFilter *walletrpc.TransparentAddressBlockFilter, resp walletrpc.CompactTxStreamer_GetAddressTxidsServer) error {
var err error
var errCode int64
// Test to make sure Address is a single t address
match, err := regexp.Match("^R[a-zA-Z0-9]{33}$", []byte(addressBlockFilter.Address))
if err != nil || !match {
s.log.Errorf("Unrecognized address: %s", addressBlockFilter.Address)
return nil
}
params := make([]json.RawMessage, 1)
st := "{\"addresses\": [\"" + addressBlockFilter.Address + "\"]," +
"\"start\": " + strconv.FormatUint(addressBlockFilter.Range.Start.Height, 10) +
", \"end\": " + strconv.FormatUint(addressBlockFilter.Range.End.Height, 10) + "}"
params[0] = json.RawMessage(st)
result, rpcErr := s.client.RawRequest("getaddresstxids", params)
// For some reason, the error responses are not JSON
if rpcErr != nil {
s.log.Errorf("Got error: %s", rpcErr.Error())
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
}
return nil
}
var txids []string
err = json.Unmarshal(result, &txids)
if err != nil {
s.log.Errorf("Got error: %s", err.Error())
return nil
}
timeout, cancel := context.WithTimeout(resp.Context(), 30*time.Second)
defer cancel()
for _, txidstr := range txids {
txid, _ := hex.DecodeString(txidstr)
// Txid is read as a string, which is in big-endian order. But when converting
// to bytes, it should be little-endian
for left, right := 0, len(txid)-1; left < right; left, right = left+1, right-1 {
txid[left], txid[right] = txid[right], txid[left]
}
tx, err := s.GetTransaction(timeout, &walletrpc.TxFilter{Hash: txid})
if err != nil {
s.log.Errorf("Got error: %s", err.Error())
return nil
}
resp.Send(tx)
}
return nil
}
// GetMempoolStream streams currently-unconfirmed (mempool) transactions to the client as full
// RawTransactions, so lightwallets can detect 0-confirmation transactions -- including reading
// shielded memos (e.g. incoming chat messages) -- without waiting for a block. Each mempool tx is
// emitted once; the stream stays open until a new block is mined, at which point it returns
// (closing the stream) so the client re-syncs the block and reconnects. This mirrors the
// zecwallet/Hush lightwalletd semantics the wallets already expect.
//
// The node's mempool is polled by a single process-wide monitor that fans out to all subscribers
// (see mempool.go), so the node isn't polled once per connected wallet.
func (s *SqlStreamer) GetMempoolStream(_ *walletrpc.Empty, resp walletrpc.CompactTxStreamer_GetMempoolStreamServer) error {
monitor := getMempoolMonitor(s.client, s.cache, s.log)
id, ch, snapshot := monitor.subscribe()
defer monitor.unsubscribe(id)
// First send the txs already in this block's mempool, so a wallet that connects mid-block still
// sees them.
for _, rtx := range snapshot {
if err := resp.Send(rtx); err != nil {
return err
}
}
// Then stream subsequent txs until the monitor closes the channel (a new block was mined) or
// the client disconnects.
for {
select {
case <-resp.Context().Done():
return nil
case rtx, ok := <-ch:
if !ok {
return nil
}
if err := resp.Send(rtx); err != nil {
return err
}
}
}
}
func (s *SqlStreamer) GetBlock(ctx context.Context, id *walletrpc.BlockID) (*walletrpc.CompactBlock, error) {
if id.Height == 0 && id.Hash == nil {
return nil, ErrUnspecified
}
// Precedence: a hash is more specific than a height. If we have it, use it first.
if id.Hash != nil {
// TODO: Get block by hash
return nil, errors.New("GetBlock by Hash is not yet implemented")
} else {
cBlock, err := common.GetBlock(s.client, s.cache, int(id.Height))
if err != nil {
return nil, err
}
return cBlock, err
}
}
func (s *SqlStreamer) GetBlockRange(span *walletrpc.BlockRange, resp walletrpc.CompactTxStreamer_GetBlockRangeServer) error {
s.log.WithFields(logrus.Fields{
"start": span.Start.Height,
"end": span.End.Height,
}).Info("GetBlockRange called")
blockChan := make(chan walletrpc.CompactBlock)
errChan := make(chan error)
go common.GetBlockRange(s.client, s.cache, blockChan, errChan, int(span.Start.Height), int(span.End.Height))
blockCount := 0
for {
select {
case err := <-errChan:
if err != nil {
s.log.WithFields(logrus.Fields{
"start": span.Start.Height,
"end": span.End.Height,
"blocksSent": blockCount,
"error": err,
}).Error("GetBlockRange error")
} else {
s.log.WithFields(logrus.Fields{
"start": span.Start.Height,
"end": span.End.Height,
"blocksSent": blockCount,
}).Info("GetBlockRange completed")
}
return err
case cBlock := <-blockChan:
err := resp.Send(&cBlock)
if err != nil {
s.log.WithFields(logrus.Fields{
"start": span.Start.Height,
"end": span.End.Height,
"blocksSent": blockCount,
"error": err,
}).Error("GetBlockRange send error")
return err
}
blockCount++
}
}
return nil
}
func (s *SqlStreamer) GetTransaction(ctx context.Context, txf *walletrpc.TxFilter) (*walletrpc.RawTransaction, error) {
var txBytes []byte
var txHeight float64
if txf.Hash != nil {
txid := txf.Hash
for left, right := 0, len(txid)-1; left < right; left, right = left+1, right-1 {
txid[left], txid[right] = txid[right], txid[left]
}
leHashString := hex.EncodeToString(txid)
// First call to get the raw transaction bytes
params := make([]json.RawMessage, 1)
params[0] = json.RawMessage("\"" + leHashString + "\"")
result, rpcErr := s.client.RawRequest("getrawtransaction", params)
var err error
var errCode int64
// For some reason, the error responses are not JSON
if rpcErr != nil {
s.log.Errorf("Got error: %s", rpcErr.Error())
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, err
}
return nil, err
}
var txhex string
err = json.Unmarshal(result, &txhex)
if err != nil {
return nil, err
}
txBytes, err = hex.DecodeString(txhex)
if err != nil {
return nil, err
}
// Second call to get height
params = make([]json.RawMessage, 2)
params[0] = json.RawMessage("\"" + leHashString + "\"")
params[1] = json.RawMessage("1")
result, rpcErr = s.client.RawRequest("getrawtransaction", params)
// For some reason, the error responses are not JSON
if rpcErr != nil {
s.log.Errorf("Got error: %s", rpcErr.Error())
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, err
}
return nil, err
}
var txinfo interface{}
err = json.Unmarshal(result, &txinfo)
if err != nil {
return nil, err
}
// dragonxd emits "height" only for a transaction that is in a block:
// rawtransaction.cpp puts it inside `if (!hashBlock.IsNull())`. Every
// mempool transaction therefore comes back WITHOUT the key, and an
// unchecked assertion on the missing value panics -- which, with no
// recover() anywhere in grpc-go v1.24.0 or in this daemon, kills the
// whole process and every wallet connected to it. Any client can reach
// this by broadcasting a transaction and immediately asking for it, and
// GetMempoolStream hands out unconfirmed txids by design.
//
// Report an unconfirmed transaction as tip+1, matching what
// GetMempoolStream already advertises (mempool.go:109).
txmap, ok := txinfo.(map[string]interface{})
if !ok {
return nil, errors.New("getrawtransaction: unexpected response shape")
}
if h, ok := txmap["height"].(float64); ok {
txHeight = h
} else {
txHeight = float64(s.cache.GetLatestBlock() + 1)
}
return &walletrpc.RawTransaction{Data: txBytes, Height: uint64(txHeight)}, nil
}
if txf.Block.Hash != nil {
s.log.Error("Can't GetTransaction with a blockhash+num. Please call GetTransaction with txid")
return nil, errors.New("Can't GetTransaction with a blockhash+num. Please call GetTransaction with txid")
}
return &walletrpc.RawTransaction{Data: txBytes, Height: uint64(txHeight)}, nil
}
// GetLightdInfo gets the LightWalletD (this server) info
func (s *SqlStreamer) GetLightdInfo(ctx context.Context, in *walletrpc.Empty) (*walletrpc.LightdInfo, error) {
saplingHeight, blockHeight, chainName, consensusBranchId, difficulty, longestchain, notarized, err := common.GetSaplingInfo(s.client)
s.log.WithFields(logrus.Fields{
"saplingHeight": saplingHeight,
"blockHeight": blockHeight,
"chainName": chainName,
"consensusBranchId": consensusBranchId,
}).Info("GetLightdInfo called")
if err != nil {
s.log.WithFields(logrus.Fields{
"error": err,
}).Warn("Unable to get sapling activation height")
return nil, err
}
// Report the advertised (lag-adjusted) tip so wallets consider themselves
// synced at the height they are actually served, not perpetually N behind.
advHeight := s.cache.AdvertisedLatestBlock()
if advHeight < 0 {
advHeight = blockHeight
}
// TODO these are called Error but they aren't at the moment.
// A success will return code 0 and message txhash.
return &walletrpc.LightdInfo{
Version: common.VersionString,
Vendor: "DragonX LightWalletD",
TaddrSupport: true,
ChainName: chainName,
SaplingActivationHeight: uint64(saplingHeight),
ConsensusBranchId: consensusBranchId,
BlockHeight: uint64(advHeight),
Difficulty: uint64(difficulty),
Longestchain: uint64(longestchain),
Notarized: uint64(notarized),
}, nil
}
// GetCoinsupply gets the Coinsupply info
func (s *SqlStreamer) GetCoinsupply(ctx context.Context, in *walletrpc.Empty) (*walletrpc.Coinsupply, error) {
result, coin, height, supply, zfunds, total, err := common.GetCoinsupply(s.client)
if err != nil {
s.log.WithFields(logrus.Fields{
"error": err,
}).Warn("Unable to get Coinsupply")
return nil, err
}
// TODO these are called Error but they aren't at the moment.
// A success will return code 0 and message txhash.
return &walletrpc.Coinsupply{
Result: result,
Coin: coin,
Height: uint64(height),
Supply: uint64(supply),
Zfunds: uint64(zfunds),
Total: uint64(total),
}, nil
}
// SendTransaction forwards raw transaction bytes to a dragonxd instance over JSON-RPC
func (s *SqlStreamer) SendTransaction(ctx context.Context, rawtx *walletrpc.RawTransaction) (*walletrpc.SendResponse, error) {
// sendrawtransaction "hexstring" ( allowhighfees )
//
// Submits raw transaction (serialized, hex-encoded) to local node and network.
//
// Also see createrawtransaction and signrawtransaction calls.
//
// Arguments:
// 1. "hexstring" (string, required) The hex string of the raw transaction)
// 2. allowhighfees (boolean, optional, default=false) Allow high fees
//
// Result:
// "hex" (string) The transaction hash in hex
// Construct raw JSON-RPC params
params := make([]json.RawMessage, 1)
txHexString := hex.EncodeToString(rawtx.Data)
params[0] = json.RawMessage("\"" + txHexString + "\"")
result, rpcErr := s.client.RawRequest("sendrawtransaction", params)
var err error
var errCode int64
var errMsg string
// For some reason, the error responses are not JSON
if rpcErr != nil {
errParts := strings.SplitN(rpcErr.Error(), ":", 2)
errMsg = strings.TrimSpace(errParts[1])
errCode, err = strconv.ParseInt(errParts[0], 10, 32)
if err != nil {
// This should never happen. We can't panic here, but it's that class of error.
// This is why we need integration testing to work better than regtest currently does. TODO.
return nil, errors.New("SendTransaction couldn't parse error code")
}
} else {
errMsg = string(result)
}
// TODO these are called Error but they aren't at the moment.
// A success will return code 0 and message txhash.
return &walletrpc.SendResponse{
ErrorCode: int32(errCode),
ErrorMessage: errMsg,
}, nil
}