GetTransaction asserted the height out of getrawtransaction's reply
without checking it:
txHeight = txinfo.(map[string]interface{})["height"].(float64)
dragonxd emits "height" only for a transaction that is in a block --
rawtransaction.cpp puts it inside `if (!hashBlock.IsNull())` -- so every
mempool transaction comes back without the key. The assertion then runs
nil.(float64) and panics. Nothing recovers it: grpc-go v1.24.0 installs
no recovery interceptor (there is no recover() in its server.go) and
this daemon adds none, so the panic takes down the whole process and
every wallet connected to that endpoint with it.
Any client can trigger it deliberately: broadcast a transaction, then
ask for it before it is mined. GetMempoolStream, added in b1b0d45,
hands out unconfirmed txids by design, so ordinary 0-conf use walks
straight into it.
Verified against a live mempool transaction on this node, whose reply
has neither "height" nor "blockhash": the old expression panics with
"interface conversion: interface {} is nil, not float64"; the new one
returns cleanly.
An unconfirmed transaction is now reported as tip+1, which is what
GetMempoolStream already advertises for the same transactions
(mempool.go:109).
Also harden GetSaplingInfo, which had six more unchecked assertions on
the getblockchaininfo reply. Those run on the block-ingestor goroutine,
where a panic is equally fatal. The top-level object is asserted once
and every field is read with the comma-ok form; a missing field now
degrades instead of crashing.
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"
|
|
|
|
"github.com/btcsuite/btcd/rpcclient"
|
|
"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 *rpcclient.Client
|
|
log *logrus.Entry
|
|
}
|
|
|
|
func NewSQLiteStreamer(client *rpcclient.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: "0.1.1-dragonxlightd",
|
|
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
|
|
}
|