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
438 lines
14 KiB
Go
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
|
|
}
|