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
277 lines
8.7 KiB
Go
277 lines
8.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/sirupsen/logrus"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials"
|
|
"google.golang.org/grpc/peer"
|
|
"google.golang.org/grpc/reflection"
|
|
|
|
"git.hush.is/hush/lightwalletd/common"
|
|
"git.hush.is/hush/lightwalletd/frontend"
|
|
"git.hush.is/hush/lightwalletd/walletrpc"
|
|
)
|
|
|
|
var log *logrus.Entry
|
|
var logger = logrus.New()
|
|
|
|
func init() {
|
|
logger.SetFormatter(&logrus.TextFormatter{
|
|
//DisableColors: true,
|
|
FullTimestamp: true,
|
|
DisableLevelTruncation: true,
|
|
})
|
|
|
|
log = logger.WithFields(logrus.Fields{
|
|
"app": "frontend-grpc",
|
|
})
|
|
}
|
|
|
|
// TODO stream logging
|
|
|
|
func LoggingInterceptor() grpc.ServerOption {
|
|
return grpc.UnaryInterceptor(logInterceptor)
|
|
}
|
|
|
|
func logInterceptor(
|
|
ctx context.Context,
|
|
req interface{},
|
|
info *grpc.UnaryServerInfo,
|
|
handler grpc.UnaryHandler,
|
|
) (interface{}, error) {
|
|
reqLog := loggerFromContext(ctx)
|
|
start := time.Now()
|
|
|
|
resp, err := handler(ctx, req)
|
|
|
|
entry := reqLog.WithFields(logrus.Fields{
|
|
"method": info.FullMethod,
|
|
"duration": time.Since(start),
|
|
"error": err,
|
|
})
|
|
|
|
if err != nil {
|
|
entry.Error("call failed")
|
|
} else {
|
|
entry.Info("method called")
|
|
}
|
|
|
|
return resp, err
|
|
}
|
|
|
|
func loggerFromContext(ctx context.Context) *logrus.Entry {
|
|
// TODO: anonymize the addresses. cryptopan?
|
|
if peerInfo, ok := peer.FromContext(ctx); ok {
|
|
return log.WithFields(logrus.Fields{"peer_addr": peerInfo.Addr})
|
|
}
|
|
return log.WithFields(logrus.Fields{"peer_addr": "unknown"})
|
|
}
|
|
|
|
type Options struct {
|
|
bindAddr string `json:"bind_address,omitempty"`
|
|
tlsCertPath string `json:"tls_cert_path,omitempty"`
|
|
tlsKeyPath string `json:"tls_cert_key,omitempty"`
|
|
noTLS bool `json:no_tls,omitempty`
|
|
logLevel uint64 `json:"log_level,omitempty"`
|
|
logPath string `json:"log_file,omitempty"`
|
|
confPath string `json:"conf_file,omitempty"`
|
|
cacheSize int `json:"cache_size,omitempty"`
|
|
|
|
adaptiveLag bool `json:"adaptive_lag,omitempty"`
|
|
lagMin int `json:"lag_min,omitempty"`
|
|
lagMax int `json:"lag_max,omitempty"`
|
|
lagWindowMin int `json:"lag_window_min,omitempty"`
|
|
|
|
rpcTimeout time.Duration `json:"rpc_timeout,omitempty"`
|
|
}
|
|
|
|
func main() {
|
|
var version = common.Version
|
|
|
|
opts := &Options{}
|
|
flag.StringVar(&opts.bindAddr, "bind-addr", "127.0.0.1:9069", "the address to listen on")
|
|
flag.StringVar(&opts.tlsCertPath, "tls-cert", "", "the path to a TLS certificate (optional)")
|
|
flag.StringVar(&opts.tlsKeyPath, "tls-key", "", "the path to a TLS key file (optional)")
|
|
flag.BoolVar(&opts.noTLS, "no-tls", false, "Disable TLS, serve un-encrypted traffic.")
|
|
flag.Uint64Var(&opts.logLevel, "log-level", uint64(logrus.InfoLevel), "log level (logrus 1-7)")
|
|
flag.StringVar(&opts.logPath, "log-file", "", "log file to write to")
|
|
flag.StringVar(&opts.confPath, "conf-file", "", "conf file to pull RPC creds from")
|
|
flag.IntVar(&opts.cacheSize, "cache-size", 400000, "number of blocks to hold in the cache")
|
|
flag.BoolVar(&opts.adaptiveLag, "adaptive-lag", true, "advertise a tip that trails the real tip by a reorg-rate-driven lag, so wallets anchor shielded spends at a settled height")
|
|
flag.IntVar(&opts.lagMin, "lag-min", 1, "minimum confirmation lag in blocks (applied when the chain is stable)")
|
|
flag.IntVar(&opts.lagMax, "lag-max", 12, "maximum confirmation lag in blocks (cap during heavy reorgs)")
|
|
flag.IntVar(&opts.lagWindowMin, "lag-window", 30, "minutes of recent reorg history used to size the adaptive lag")
|
|
flag.DurationVar(&opts.rpcTimeout, "rpc-timeout", frontend.DefaultRPCTimeout, "bound a single dragonxd JSON-RPC call; 0 disables. One stuck call otherwise blocks every other caller, because HTTP POST mode serialises all RPC through one goroutine")
|
|
|
|
// creating --version as a requirement of help2man
|
|
if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-v") {
|
|
fmt.Printf("DragonX lightwalletd version " + version + "\n")
|
|
os.Exit(0)
|
|
}
|
|
|
|
// TODO prod metrics
|
|
// TODO support config from file and env vars
|
|
flag.Parse()
|
|
|
|
if opts.confPath == "" {
|
|
flag.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
if !opts.noTLS && (opts.tlsCertPath == "" || opts.tlsKeyPath == "") {
|
|
println("Please specify a TLS certificate/key to use. You can use a self-signed certificate.")
|
|
println("See https://git.hush.is/hush/lightwalletd/src/branch/master/README.md")
|
|
os.Exit(1)
|
|
}
|
|
|
|
if opts.logPath != "" {
|
|
// instead write parsable logs for logstash/splunk/etc
|
|
output, err := os.OpenFile(opts.logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
if err != nil {
|
|
log.WithFields(logrus.Fields{
|
|
"error": err,
|
|
"path": opts.logPath,
|
|
}).Fatal("couldn't open log file")
|
|
}
|
|
defer output.Close()
|
|
logger.SetOutput(output)
|
|
logger.SetFormatter(&logrus.JSONFormatter{})
|
|
}
|
|
|
|
logger.SetLevel(logrus.Level(opts.logLevel))
|
|
|
|
// gRPC initialization
|
|
var server *grpc.Server
|
|
|
|
if !opts.noTLS && (opts.tlsCertPath != "" && opts.tlsKeyPath != "") {
|
|
transportCreds, err := credentials.NewServerTLSFromFile(opts.tlsCertPath, opts.tlsKeyPath)
|
|
if err != nil {
|
|
log.WithFields(logrus.Fields{
|
|
"cert_file": opts.tlsCertPath,
|
|
"key_path": opts.tlsKeyPath,
|
|
"error": err,
|
|
}).Fatal("couldn't load TLS credentials")
|
|
}
|
|
server = grpc.NewServer(grpc.Creds(transportCreds), LoggingInterceptor())
|
|
} else {
|
|
server = grpc.NewServer(LoggingInterceptor())
|
|
}
|
|
|
|
// Enable reflection for debugging
|
|
if opts.logLevel >= uint64(logrus.WarnLevel) {
|
|
reflection.Register(server)
|
|
}
|
|
|
|
// Initialize DragonX RPC client. Right now this is only for
|
|
// sending transactions, but in the future it could back a different type
|
|
// of block streamer.
|
|
|
|
rpcClient, err := frontend.NewZRPCFromConf(opts.confPath, opts.rpcTimeout)
|
|
if err != nil {
|
|
log.WithFields(logrus.Fields{
|
|
"error": err,
|
|
}).Warn("DRAGONX.conf failed, will try empty credentials for rpc")
|
|
|
|
rpcClient, err = frontend.NewZRPCFromCreds("127.0.0.1:21769", "", "", opts.rpcTimeout)
|
|
|
|
if err != nil {
|
|
log.WithFields(logrus.Fields{
|
|
"error": err,
|
|
}).Warn("couldn't start rpc conn. won't be able to send transactions")
|
|
}
|
|
}
|
|
|
|
// Get the sapling activation height from the RPC
|
|
saplingHeight, blockHeight, chainName, branchID, difficulty, longestchain, notarized, err := common.GetSaplingInfo(rpcClient)
|
|
if err != nil {
|
|
log.WithFields(logrus.Fields{
|
|
"error": err,
|
|
}).Warn("Unable to get sapling activation height")
|
|
}
|
|
|
|
log.Info("Got sapling height ", saplingHeight, " chain ", chainName, " branchID ", branchID, " difficulty ", difficulty, longestchain, " longestchain ", notarized, " notarized ")
|
|
|
|
// Fetch coinsupply for an informational startup log line only (the result is not
|
|
// used elsewhere). On a node whose supply index is cold this RPC can take minutes,
|
|
// so run it in the background rather than blocking the gRPC bind on it — otherwise
|
|
// the lite endpoint stays down for the entire duration on every restart. Clients
|
|
// still get coinsupply on demand via the GetCoinsupply RPC.
|
|
go func() {
|
|
result, coin, height, supply, zfunds, total, err := common.GetCoinsupply(rpcClient)
|
|
if err != nil {
|
|
log.WithFields(logrus.Fields{
|
|
"error": err,
|
|
}).Warn("Unable to get coinsupply")
|
|
return
|
|
}
|
|
log.Info(" result ", result, " coin ", coin, " height", height, "supply", supply, "zfunds", zfunds, "total", total)
|
|
}()
|
|
|
|
// Initialize the cache
|
|
cache := common.NewBlockCache(opts.cacheSize)
|
|
cache.ConfigureLag(opts.adaptiveLag, opts.lagWindowMin, opts.lagMin, opts.lagMax)
|
|
|
|
stopChan := make(chan bool, 1)
|
|
|
|
// Start the block cache importer at latestblock - 100k(cache size)
|
|
cacheStart := blockHeight - opts.cacheSize
|
|
if cacheStart < saplingHeight {
|
|
cacheStart = saplingHeight
|
|
}
|
|
|
|
go common.BlockIngestor(rpcClient, cache, log, stopChan, cacheStart)
|
|
|
|
// Compact transaction service initialization
|
|
service, err := frontend.NewSQLiteStreamer(rpcClient, cache, log)
|
|
if err != nil {
|
|
log.WithFields(logrus.Fields{
|
|
"error": err,
|
|
}).Fatal("couldn't create SQL backend")
|
|
}
|
|
defer service.(*frontend.SqlStreamer).GracefulStop()
|
|
|
|
// Register service
|
|
walletrpc.RegisterCompactTxStreamerServer(server, service)
|
|
|
|
// Start listening
|
|
listener, err := net.Listen("tcp", opts.bindAddr)
|
|
if err != nil {
|
|
log.WithFields(logrus.Fields{
|
|
"bind_addr": opts.bindAddr,
|
|
"error": err,
|
|
}).Fatal("couldn't create listener")
|
|
}
|
|
|
|
// Signal handler for graceful stops
|
|
signals := make(chan os.Signal, 1)
|
|
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
|
|
go func() {
|
|
s := <-signals
|
|
log.WithFields(logrus.Fields{
|
|
"signal": s.String(),
|
|
}).Info("caught signal, stopping gRPC server")
|
|
// Stop the server
|
|
server.GracefulStop()
|
|
// Stop the block ingestor
|
|
stopChan <- true
|
|
}()
|
|
|
|
log.Infof("Starting gRPC server on %s", opts.bindAddr)
|
|
|
|
err = server.Serve(listener)
|
|
if err != nil {
|
|
log.WithFields(logrus.Fields{
|
|
"error": err,
|
|
}).Fatal("gRPC server exited")
|
|
}
|
|
}
|