diff --git a/btcjson/chainsvrresults.go b/btcjson/chainsvrresults.go index ad71dcd3..edc4b52e 100644 --- a/btcjson/chainsvrresults.go +++ b/btcjson/chainsvrresults.go @@ -35,35 +35,37 @@ type GetBlockHeaderVerboseResult struct { } // GetBlockStatsResult models the data from the getblockstats command. +// Pointers are used instead of values to allow for optional fields. type GetBlockStatsResult struct { - AverageFee int64 `json:"avgfee"` - AverageFeeRate int64 `json:"avgfeerate"` - AverageTxSize int64 `json:"avgtxsize"` - FeeratePercentiles []int64 `json:"feerate_percentiles"` - Hash string `json:"blockhash"` - Height int64 `json:"height"` - Ins int64 `json:"ins"` - MaxFee int64 `json:"maxfee"` - MaxFeeRate int64 `json:"maxfeerate"` - MaxTxSize int64 `json:"maxtxsize"` - MedianFee int64 `json:"medianfee"` - MedianTime int64 `json:"mediantime"` - MedianTxSize int64 `json:"mediantxsize"` - MinFee int64 `json:"minfee"` - MinFeeRate int64 `json:"minfeerate"` - MinTxSize int64 `json:"mintxsize"` - Outs int64 `json:"outs"` - SegWitTotalSize int64 `json:"swtotal_size"` - SegWitTotalWeight int64 `json:"swtotal_weight"` - SegWitTxs int64 `json:"swtxs"` - Subsidy int64 `json:"subsidy"` - Time int64 `json:"time"` - TotalOut int64 `json:"total_out"` - TotalSize int64 `json:"total_size"` - TotalWeight int64 `json:"total_weight"` - Txs int64 `json:"txs"` - UTXOIncrease int64 `json:"utxo_increase"` - UTXOSizeIncrease int64 `json:"utxo_size_inc"` + AverageFee *int64 `json:"avgfee,omitempty"` + AverageFeeRate *int64 `json:"avgfeerate,omitempty"` + AverageTxSize *int64 `json:"avgtxsize,omitempty"` + FeeratePercentiles *[]int64 `json:"feerate_percentiles,omitempty"` + Hash *string `json:"blockhash,omitempty"` + Height *int64 `json:"height,omitempty"` + Ins *int64 `json:"ins,omitempty"` + MaxFee *int64 `json:"maxfee,omitempty"` + MaxFeeRate *int64 `json:"maxfeerate,omitempty"` + MaxTxSize *int64 `json:"maxtxsize,omitempty"` + MedianFee *int64 `json:"medianfee,omitempty"` + MedianTime *int64 `json:"mediantime,omitempty"` + MedianTxSize *int64 `json:"mediantxsize,omitempty"` + MinFee *int64 `json:"minfee,omitempty"` + MinFeeRate *int64 `json:"minfeerate,omitempty"` + MinTxSize *int64 `json:"mintxsize,omitempty"` + Outs *int64 `json:"outs,omitempty"` + SegWitTotalSize *int64 `json:"swtotal_size,omitempty"` + SegWitTotalWeight *int64 `json:"swtotal_weight,omitempty"` + SegWitTxs *int64 `json:"swtxs,omitempty"` + Subsidy *int64 `json:"subsidy,omitempty"` + Time *int64 `json:"time,omitempty"` + TotalOut *int64 `json:"total_out,omitempty"` + TotalSize *int64 `json:"total_size,omitempty"` + TotalWeight *int64 `json:"total_weight,omitempty"` + TotalFee *int64 `json:"totalfee,omitempty"` + Txs *int64 `json:"txs,omitempty"` + UTXOIncrease *int64 `json:"utxo_increase,omitempty"` + UTXOSizeIncrease *int64 `json:"utxo_size_inc,omitempty"` } type GetBlockVerboseResultBase struct { diff --git a/integration/rpcserver_test.go b/integration/rpcserver_test.go index 2ed6b408..970454c7 100644 --- a/integration/rpcserver_test.go +++ b/integration/rpcserver_test.go @@ -13,7 +13,9 @@ import ( "fmt" "os" "runtime/debug" + "sort" "testing" + "time" "github.com/lbryio/lbcd/chaincfg" "github.com/lbryio/lbcd/chaincfg/chainhash" @@ -133,13 +135,278 @@ func testBulkClient(r *rpctest.Harness, t *testing.T) { t.Fatalf("expected hash %s to be in generated hash list", blockHash) } } +} +func testGetBlockStats(r *rpctest.Harness, t *testing.T) { + t.Parallel() + + baseFeeRate := int64(10) + txValue := int64(50000000) + txQuantity := 10 + txs := make([]*btcutil.Tx, txQuantity) + fees := make([]int64, txQuantity) + sizes := make([]int64, txQuantity) + feeRates := make([]int64, txQuantity) + var outputCount int + + // Generate test sample. + for i := 0; i < txQuantity; i++ { + address, err := r.NewAddress() + if err != nil { + t.Fatalf("Unable to generate address: %v", err) + } + + pkScript, err := txscript.PayToAddrScript(address) + if err != nil { + t.Fatalf("Unable to generate PKScript: %v", err) + } + + // This feerate is not the actual feerate. See comment below. + feeRate := baseFeeRate * int64(i) + + tx, err := r.CreateTransaction([]*wire.TxOut{wire.NewTxOut(txValue, pkScript)}, btcutil.Amount(feeRate), true) + if err != nil { + t.Fatalf("Unable to generate segwit transaction: %v", err) + } + + txs[i] = btcutil.NewTx(tx) + sizes[i] = int64(tx.SerializeSize()) + + // memWallet.fundTx makes some assumptions when calculating fees. + // For instance, it assumes the signature script has exactly 108 bytes + // and it does not account for the size of the change output. + // This needs to be taken into account when getting the true feerate. + scriptSigOffset := 108 - len(tx.TxIn[0].SignatureScript) + changeOutputSize := tx.TxOut[len(tx.TxOut)-1].SerializeSize() + fees[i] = (sizes[i] + int64(scriptSigOffset) - int64(changeOutputSize)) * feeRate + feeRates[i] = fees[i] / sizes[i] + + outputCount += len(tx.TxOut) + } + + stats := func(slice []int64) (int64, int64, int64, int64, int64) { + var total, average, min, max, median int64 + min = slice[0] + length := len(slice) + for _, item := range slice { + if min > item { + min = item + } + if max < item { + max = item + } + total += item + } + average = total / int64(length) + sort.Slice(slice, func(i, j int) bool { return slice[i] < slice[j] }) + if length == 0 { + median = 0 + } else if length%2 == 0 { + median = (slice[length/2-1] + slice[length/2]) / 2 + } else { + median = slice[length/2] + } + return total, average, min, max, median + } + + totalFee, avgFee, minFee, maxFee, medianFee := stats(fees) + totalSize, avgSize, minSize, maxSize, medianSize := stats(sizes) + _, avgFeeRate, minFeeRate, maxFeeRate, _ := stats(feeRates) + + tests := []struct { + name string + txs []*btcutil.Tx + stats []string + expectedResults map[string]interface{} + }{ + { + name: "empty block", + txs: []*btcutil.Tx{}, + stats: []string{}, + expectedResults: map[string]interface{}{ + "avgfee": int64(0), + "avgfeerate": int64(0), + "avgtxsize": int64(0), + "feerate_percentiles": []int64{0, 0, 0, 0, 0}, + "ins": int64(0), + "maxfee": int64(0), + "maxfeerate": int64(0), + "maxtxsize": int64(0), + "medianfee": int64(0), + "mediantxsize": int64(0), + "minfee": int64(0), + "mintxsize": int64(0), + "outs": int64(1), + "swtotal_size": int64(0), + "swtotal_weight": int64(0), + "swtxs": int64(0), + "total_out": int64(0), + "total_size": int64(0), + "total_weight": int64(0), + "txs": int64(1), + "utxo_increase": int64(1), + }, + }, + { + name: "block with 10 transactions + coinbase", + txs: txs, + stats: []string{"avgfee", "avgfeerate", "avgtxsize", "feerate_percentiles", + "ins", "maxfee", "maxfeerate", "maxtxsize", "medianfee", "mediantxsize", + "minfee", "minfeerate", "mintxsize", "outs", "subsidy", "swtxs", + "total_size", "total_weight", "totalfee", "txs", "utxo_increase"}, + expectedResults: map[string]interface{}{ + "avgfee": avgFee, + "avgfeerate": avgFeeRate, + "avgtxsize": avgSize, + "feerate_percentiles": []int64{feeRates[0], feeRates[2], + feeRates[4], feeRates[7], feeRates[8]}, + "ins": int64(txQuantity), + "maxfee": maxFee, + "maxfeerate": maxFeeRate, + "maxtxsize": maxSize, + "medianfee": medianFee, + "mediantxsize": medianSize, + "minfee": minFee, + "minfeerate": minFeeRate, + "mintxsize": minSize, + "outs": int64(outputCount + 1), // Coinbase output also counts. + "subsidy": int64(5000000000), + "swtotal_weight": nil, // This stat was not selected, so it should be nil. + "swtxs": int64(0), + "total_size": totalSize, + "total_weight": totalSize * 4, + "totalfee": totalFee, + "txs": int64(txQuantity + 1), // Coinbase transaction also counts. + "utxo_increase": int64(outputCount + 1 - txQuantity), + "utxo_size_inc": nil, + }, + }, + } + for _, test := range tests { + // Submit a new block with the provided transactions. + block, err := r.GenerateAndSubmitBlock(test.txs, -1, time.Time{}) + if err != nil { + t.Fatalf("Unable to generate block: %v from test %s", err, test.name) + } + + blockStats, err := r.Node.GetBlockStats(block.Hash(), &test.stats) + if err != nil { + t.Fatalf("Call to `getblockstats` on test %s failed: %v", test.name, err) + } + + if blockStats.Height != (*int64)(nil) && *blockStats.Height != int64(block.Height()) { + t.Fatalf("Unexpected result in test %s, stat: %v, expected: %v, got: %v", test.name, "height", block.Height(), *blockStats.Height) + } + + for stat, value := range test.expectedResults { + var result interface{} + switch stat { + case "avgfee": + result = blockStats.AverageFee + case "avgfeerate": + result = blockStats.AverageFeeRate + case "avgtxsize": + result = blockStats.AverageTxSize + case "feerate_percentiles": + result = blockStats.FeeratePercentiles + case "blockhash": + result = blockStats.Hash + case "height": + result = blockStats.Height + case "ins": + result = blockStats.Ins + case "maxfee": + result = blockStats.MaxFee + case "maxfeerate": + result = blockStats.MaxFeeRate + case "maxtxsize": + result = blockStats.MaxTxSize + case "medianfee": + result = blockStats.MedianFee + case "mediantime": + result = blockStats.MedianTime + case "mediantxsize": + result = blockStats.MedianTxSize + case "minfee": + result = blockStats.MinFee + case "minfeerate": + result = blockStats.MinFeeRate + case "mintxsize": + result = blockStats.MinTxSize + case "outs": + result = blockStats.Outs + case "swtotal_size": + result = blockStats.SegWitTotalSize + case "swtotal_weight": + result = blockStats.SegWitTotalWeight + case "swtxs": + result = blockStats.SegWitTxs + case "subsidy": + result = blockStats.Subsidy + case "time": + result = blockStats.Time + case "total_out": + result = blockStats.TotalOut + case "total_size": + result = blockStats.TotalSize + case "total_weight": + result = blockStats.TotalWeight + case "totalfee": + result = blockStats.TotalFee + case "txs": + result = blockStats.Txs + case "utxo_increase": + result = blockStats.UTXOIncrease + case "utxo_size_inc": + result = blockStats.UTXOSizeIncrease + } + + var equality bool + + // Check for nil equality. + if value == nil && result == (*int64)(nil) { + equality = true + break + } else if result == nil || value == nil { + equality = false + } + + var resultValue interface{} + switch v := value.(type) { + case int64: + resultValue = *result.(*int64) + equality = v == resultValue + case string: + resultValue = *result.(*string) + equality = v == resultValue + case []int64: + resultValue = *result.(*[]int64) + resultSlice := resultValue.([]int64) + equality = true + for i, item := range resultSlice { + if item != v[i] { + equality = false + break + } + } + } + if !equality { + if result != nil { + t.Fatalf("Unexpected result in test %s, stat: %v, expected: %v, got: %v", test.name, stat, value, resultValue) + } else { + t.Fatalf("Unexpected result in test %s, stat: %v, expected: %v, got: %v", test.name, stat, value, "") + } + } + + } + } } var rpcTestCases = []rpctest.HarnessTestCase{ testGetBestBlock, testGetBlockCount, testGetBlockHash, + testGetBlockStats, testBulkClient, } @@ -151,7 +418,8 @@ func TestMain(m *testing.M) { // In order to properly test scenarios on as if we were on mainnet, // ensure that non-standard transactions aren't accepted into the // mempool or relayed. - btcdCfg := []string{"--rejectnonstd"} + // Enable transaction index to be able to fully test GetBlockStats + btcdCfg := []string{"--rejectnonstd", "--txindex"} primaryHarness, err = rpctest.New( &chaincfg.SimNetParams, nil, btcdCfg, "", ) diff --git a/rpcserver.go b/rpcserver.go index 742f65ab..3cc71472 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -21,6 +21,7 @@ import ( "net" "net/http" "os" + "sort" "strconv" "strings" "sync" @@ -151,6 +152,7 @@ var rpcHandlersBeforeInit = map[string]commandHandler{ "getblockcount": handleGetBlockCount, "getblockhash": handleGetBlockHash, "getblockheader": handleGetBlockHeader, + "getblockstats": handleGetBlockStats, "getblocktemplate": handleGetBlockTemplate, "getcfilter": handleGetCFilter, "getcfilterheader": handleGetCFilterHeader, @@ -1578,6 +1580,402 @@ func handleGetChainTips(s *rpcServer, cmd interface{}, closeChan <-chan struct{} return results, nil } +// handleGetBlockStats implements the getblockstats command. +func handleGetBlockStats(s *rpcServer, cmd interface{}, closeChan <-chan struct{}) (interface{}, error) { + c := cmd.(*btcjson.GetBlockStatsCmd) + + // Check whether a block height or hash was provided. + blockHeight, ok := c.HashOrHeight.Value.(int) + var hash *chainhash.Hash + var err error + if ok { + // Block height was provided. + hash, err = s.cfg.Chain.BlockHashByHeight(int32(blockHeight)) + if err != nil { + return nil, &btcjson.RPCError{ + Code: btcjson.ErrRPCOutOfRange, + Message: "Block number out of range", + } + } + } else { + // Block hash was provided. + hashString := c.HashOrHeight.Value.(string) + hash, err = chainhash.NewHashFromStr(hashString) + if err != nil { + return nil, rpcDecodeHexError(hashString) + } + + // Get the block height from chain. + blockHeightByHash, err := s.cfg.Chain.BlockHeightByHash(hash) + if err != nil { + context := "Failed to obtain block height" + return nil, internalRPCError(err.Error(), context) + } + blockHeight = int(blockHeightByHash) + } + + // Load block bytes from the database. + var blkBytes []byte + err = s.cfg.DB.View(func(dbTx database.Tx) error { + var err error + blkBytes, err = dbTx.FetchBlock(hash) + return err + }) + if err != nil { + return nil, &btcjson.RPCError{ + Code: btcjson.ErrRPCBlockNotFound, + Message: "Block not found", + } + } + + // Deserialize the block. + blk, err := btcutil.NewBlockFromBytes(blkBytes) + if err != nil { + context := "Failed to deserialize block" + return nil, internalRPCError(err.Error(), context) + } + + selectedStats := c.Stats + + // Create a set of selected stats to facilitate queries. + statsSet := make(map[string]bool) + for _, value := range *selectedStats { + statsSet[value] = true + } + + // Return all stats if an empty array was provided. + allStats := len(*selectedStats) == 0 + calcFees := statsSet["avgfee"] || statsSet["avgfeerate"] || statsSet["maxfee"] || statsSet["maxfeerate"] || + statsSet["medianfee"] || statsSet["totalfee"] || statsSet["feerate_percentiles"] + + if calcFees && s.cfg.TxIndex == nil { + return nil, &btcjson.RPCError{ + Code: btcjson.ErrRPCNoTxInfo, + Message: "The transaction index must be " + + "enabled to obtain fee statistics " + + "(specify --txindex)", + } + } + + txs := blk.Transactions() + txCount := len(txs) + var inputCount, outputCount int + var totalOutputValue int64 + + // Create a map of transaction statistics. + txStats := make([]map[string]interface{}, txCount) + for i, tx := range txs { + size := tx.MsgTx().SerializeSize() + witnessSize := size - tx.MsgTx().SerializeSizeStripped() + weight := int64(tx.MsgTx().SerializeSizeStripped()*4 + witnessSize) + + var fee, feeRate int64 + if (calcFees || allStats) && s.cfg.TxIndex != nil && !blockchain.IsCoinBaseTx(tx.MsgTx()) { + fee, err = calculateFee(tx, s.cfg.TxIndex, s.cfg.DB) + if err != nil { + context := "Failed to calculate fees" + return nil, internalRPCError(err.Error(), context) + } + if weight != 0 { + feeRate = fee * 4 / weight + } + } + segwit := tx.HasWitness() + txStats[i] = map[string]interface{}{"tx": tx, "fee": fee, "size": int64(size), + "feeRate": feeRate, "weight": weight, "segwit": segwit} + inputCount += len(tx.MsgTx().TxIn) + outputCount += len(tx.MsgTx().TxOut) + + // Coinbase is excluded from the total output. + if !blockchain.IsCoinBase(tx) { + for _, txOut := range tx.MsgTx().TxOut { + totalOutputValue += txOut.Value + } + } + } + + var totalFees, minFee, maxFee, minFeeRate, maxFeeRate, segwitCount, + segwitWeight, totalWeight, totalSize, minSize, maxSize, segwitSize int64 + if txCount > 1 { + minFee = txStats[1]["fee"].(int64) + minFeeRate = txStats[1]["feeRate"].(int64) + } + for i := 0; i < len(txStats); i++ { + var fee, feeRate int64 + tx := txStats[i]["tx"].(*btcutil.Tx) + if !blockchain.IsCoinBaseTx(tx.MsgTx()) { + // Fee statistics. + fee = txStats[i]["fee"].(int64) + feeRate = txStats[i]["feeRate"].(int64) + if minFee > fee { + minFee = fee + } + if maxFee < fee { + maxFee = fee + } + if minFeeRate > feeRate { + minFeeRate = feeRate + } + if maxFeeRate < feeRate { + maxFeeRate = feeRate + } + totalFees += txStats[i]["fee"].(int64) + + // Segwit statistics. + if txStats[i]["segwit"].(bool) { + segwitCount++ + segwitSize += txStats[i]["size"].(int64) + segwitWeight += txStats[i]["weight"].(int64) + } + + // Size statistics. + size := txStats[i]["size"].(int64) + if minSize == 0 { + minSize = size + } + if maxSize < size { + maxSize = size + } else if minSize > size { + minSize = size + } + totalSize += txStats[i]["size"].(int64) + + totalWeight += txStats[i]["weight"].(int64) + } + } + + var avgFee, avgFeeRate, avgSize int64 + if txCount > 1 { + avgFee = totalFees / int64(txCount-1) + } + if totalWeight != 0 { + avgFeeRate = totalFees * 4 / totalWeight + } + if txCount > 1 { + avgSize = totalSize / int64(txCount-1) + } + + subsidy := blockchain.CalcBlockSubsidy(int32(blockHeight), s.cfg.ChainParams) + + medianStat := func(stat string) int64 { + size := len(txStats) - 1 + if size == 0 { + return 0 + } + statArray := make([]int64, size) + // Start with the second element to ignore entry associated with coinbase. + for i, stats := range txStats[1:] { + statArray[i] = stats[stat].(int64) + } + sort.Slice(statArray, func(i, j int) bool { + return statArray[i] < statArray[j] + }) + if size%2 == 0 { + return (statArray[size/2-1] + statArray[size/2]) / 2 + } + return statArray[size/2] + } + + var medianFee int64 + if totalFees > 0 { + medianFee = medianStat("fee") + } else { + medianFee = 0 + } + medianSize := medianStat("size") + + // Calculate feerate percentiles. + var feeratePercentiles []int64 + if allStats || calcFees { + + // Sort by feerate. + sort.Slice(txStats, func(i, j int) bool { + return txStats[i]["feeRate"].(int64) < txStats[j]["feeRate"].(int64) + }) + totalWeight := float64(totalWeight) + + // Find 10th, 25th, 50th, 75th and 90th percentile weight units. + weights := []float64{ + totalWeight / 10, totalWeight / 4, totalWeight / 2, + (totalWeight * 3) / 4, (totalWeight * 9) / 10} + var cumulativeWeight int64 + feeratePercentiles = make([]int64, len(weights)) + nextPercentileIndex := 0 + for i := 0; i < len(txStats); i++ { + cumulativeWeight += txStats[i]["weight"].(int64) + for nextPercentileIndex < len(weights) && float64(cumulativeWeight) >= weights[nextPercentileIndex] { + feeratePercentiles[nextPercentileIndex] = txStats[i]["feeRate"].(int64) + nextPercentileIndex++ + } + } + + // Fill any remaining percentiles with the last value. + for i := nextPercentileIndex; i < len(weights); i++ { + feeratePercentiles[i] = txStats[len(txStats)-1]["feeRate"].(int64) + } + } + + var blockHash string + if allStats || statsSet["blockhash"] { + blockHash = blk.Hash().String() + } + + medianTime, err := medianBlockTime(blk.Hash(), s.cfg.Chain) + if err != nil { + context := "Failed to obtain block median time" + return nil, internalRPCError(err.Error(), context) + } + + resultMap := map[string]int64{ + "avgfee": avgFee, + "avgfeerate": avgFeeRate, + "avgtxsize": avgSize, + "height": int64(blockHeight), + "ins": int64(inputCount - 1), // Coinbase input is not included. + "maxfee": maxFee, + "maxfeerate": maxFeeRate, + "maxtxsize": maxSize, + "medianfee": medianFee, + "mediantime": medianTime.Unix(), + "mediantxsize": medianSize, + "minfee": minFee, + "minfeerate": minFeeRate, + "mintxsize": minSize, + "outs": int64(outputCount), + "swtotal_size": segwitSize, + "swtotal_weight": segwitWeight, + "swtxs": segwitCount, + "subsidy": subsidy, + "time": blk.MsgBlock().Header.Timestamp.Unix(), + "total_out": totalOutputValue, + "total_size": totalSize, + "total_weight": totalWeight, + "totalfee": totalFees, + "txs": int64(len(txs)), + "utxo_increase": int64(outputCount - (inputCount - 1)), + } + + // This function determines whether a statistic goes into the + // final result, except for blockhash and feerate_percentiles + // which are handled separately. + resultFilter := func(stat string) *int64 { + if allStats && s.cfg.TxIndex == nil { + // There are no fee statistics to send. + excludedStats := []string{"avgfee", "avgfeerate", "maxfee", "maxfeerate", "medianfee", "minfee", "minfeerate"} + for _, excluded := range excludedStats { + if stat == excluded { + return nil + } + } + } + if allStats || statsSet[stat] { + if value, ok := resultMap[stat]; ok { + return &value + } + } + return nil + } + + result := &btcjson.GetBlockStatsResult{ + AverageFee: resultFilter("avgfee"), + AverageFeeRate: resultFilter("avgfeerate"), + AverageTxSize: resultFilter("avgtxsize"), + FeeratePercentiles: &feeratePercentiles, + Hash: &blockHash, + Height: resultFilter("height"), + Ins: resultFilter("ins"), + MaxFee: resultFilter("maxfee"), + MaxFeeRate: resultFilter("maxfeerate"), + MaxTxSize: resultFilter("maxtxsize"), + MedianFee: resultFilter("medianfee"), + MedianTime: resultFilter("mediantime"), + MedianTxSize: resultFilter("mediantxsize"), + MinFee: resultFilter("minfee"), + MinFeeRate: resultFilter("minfeerate"), + MinTxSize: resultFilter("mintxsize"), + Outs: resultFilter("outs"), + SegWitTotalSize: resultFilter("swtotal_size"), + SegWitTotalWeight: resultFilter("swtotal_weight"), + SegWitTxs: resultFilter("swtxs"), + Subsidy: resultFilter("subsidy"), + Time: resultFilter("time"), + TotalOut: resultFilter("total_out"), + TotalSize: resultFilter("total_size"), + TotalWeight: resultFilter("total_weight"), + TotalFee: resultFilter("totalfee"), + Txs: resultFilter("txs"), + UTXOIncrease: resultFilter("utxo_increase"), + UTXOSizeIncrease: resultFilter("utxo_size_inc"), + } + return result, nil +} + +// calculateFee returns the fee of a transaction. +func calculateFee(tx *btcutil.Tx, txIndex *indexers.TxIndex, db database.DB) (int64, error) { + var inValue, outValue int64 + for _, input := range tx.MsgTx().TxIn { + prevTxHash := input.PreviousOutPoint.Hash + // Look up the location of the previous transaction in the index. + blockRegion, err := txIndex.TxBlockRegion(&prevTxHash) + if err != nil { + context := "Failed to retrieve transaction location" + return 0, internalRPCError(err.Error(), context) + } + if blockRegion == nil { + return 0, rpcNoTxInfoError(&prevTxHash) + } + + // Load the raw transaction bytes from the database. + var txBytes []byte + err = db.View(func(dbTx database.Tx) error { + var err error + txBytes, err = dbTx.FetchBlockRegion(blockRegion) + return err + }) + if err != nil { + return 0, rpcNoTxInfoError(&prevTxHash) + } + + var msgTx wire.MsgTx + err = msgTx.Deserialize(bytes.NewReader(txBytes)) + if err != nil { + context := "Failed to deserialize transaction" + return 0, internalRPCError(err.Error(), context) + } + prevOutValue := msgTx.TxOut[input.PreviousOutPoint.Index].Value + inValue += prevOutValue + } + for _, output := range tx.MsgTx().TxOut { + outValue += output.Value + } + fee := inValue - outValue + return fee, nil +} + +// medianBlockTime returns the median time of a block and its 10 previous blocks +// as per BIP113. +func medianBlockTime(blockHash *chainhash.Hash, chain *blockchain.BlockChain) (*time.Time, error) { + blockTimes := make([]time.Time, 0) + currentHash := blockHash + for i := 0; i < 11; i++ { + header, err := chain.HeaderByHash(currentHash) + if err != nil { + return nil, err + } + blockTimes = append(blockTimes, header.Timestamp) + genesisPrevBlock, _ := chainhash.NewHashFromStr("0000000000000000000000000000000000000000000000000000000000000000") + if header.PrevBlock.IsEqual(genesisPrevBlock) { + // This is the genesis block so there's no need to iterate further. + break + } + currentHash = &header.PrevBlock + } + sort.Slice(blockTimes, func(i, j int) bool { + return blockTimes[i].Before(blockTimes[j]) + }) + return &blockTimes[len(blockTimes)/2], nil +} + // encodeTemplateID encodes the passed details into an ID that can be used to // uniquely identify a block template. func encodeTemplateID(prevHash *chainhash.Hash, lastGenerated time.Time) string { diff --git a/rpcserverhelp.go b/rpcserverhelp.go index 44d92683..06dbba53 100644 --- a/rpcserverhelp.go +++ b/rpcserverhelp.go @@ -200,6 +200,43 @@ var helpDescsEnUS = map[string]string{ "getblockchaininforesult-softforks": "The status of the super-majority soft-forks", "getblockchaininforesult-unifiedsoftforks": "The status of the super-majority soft-forks used by bitcoind on or after v0.19.0", + // GetBlockStatsCmd help. + "getblockstats--synopsis": "Returns statistics about a block given its hash or height. --txindex must be enabled for fee and feerate statistics.", + "getblockstats-hashorheight": "The hash or height of the block", + "hashorheight-value": "The hash or height of the block", + "getblockstats-stats": "Selected statistics", + + // GetBlockStatsResult help. + "getblockstatsresult-avgfee": "The average fee in the block", + "getblockstatsresult-avgfeerate": "The average feerate in the block (in satoshis per virtual byte)", + "getblockstatsresult-avgtxsize": "The average transaction size in the block", + "getblockstatsresult-blockhash": "The block hash", + "getblockstatsresult-feerate_percentiles": "Feerates at the 10th, 25th, 50th, 75th, and 90th percentile weight unit (in satoshis per virtual byte)", + "getblockstatsresult-height": "The block height", + "getblockstatsresult-ins": "The number of inputs (excluding coinbase)", + "getblockstatsresult-maxfee": "Maxium fee in the block", + "getblockstatsresult-maxfeerate": "Maximum feerate in the block (in satoshis per virtual byte)", + "getblockstatsresult-maxtxsize": "Maximum transaction size", + "getblockstatsresult-medianfee": "Truncated median fee", + "getblockstatsresult-mediantime": "The median time from the block and its previous 10 blocks (BIP113)", + "getblockstatsresult-mediantxsize": "Truncated median transaction size", + "getblockstatsresult-minfee": "Minimum fee in the block", + "getblockstatsresult-minfeerate": "Minimum feerate in the block (in satoshis per virtual byte)", + "getblockstatsresult-mintxsize": "Minimum transaction size", + "getblockstatsresult-outs": "The number of outputs", + "getblockstatsresult-subsidy": "The block subsidy", + "getblockstatsresult-swtotal_size": "Total size of all segwit transactions in the block (excluding coinbase)", + "getblockstatsresult-swtotal_weight": "Total weight of all segwit transactions in the block (excluding coinbase)", + "getblockstatsresult-swtxs": "The number of segwit transactions in the block (excluding coinbase)", + "getblockstatsresult-time": "The block time", + "getblockstatsresult-total_out": "Total amount in all outputs (excluding coinbase)", + "getblockstatsresult-total_size": "Total size of all transactions (excluding coinbase)", + "getblockstatsresult-total_weight": "Total weight of all transactions (excluding coinbase)", + "getblockstatsresult-totalfee": "The total of fees", + "getblockstatsresult-txs": "The number of transactions (excluding coinbase)", + "getblockstatsresult-utxo_increase": "The increase/decrease in the number of unspent outputs", + "getblockstatsresult-utxo_size_inc": "The increase/decrease in size for the utxo index", + // SoftForkDescription help. "softforkdescription-reject": "The current activation status of the softfork", "softforkdescription-version": "The block version that signals enforcement of this softfork", @@ -917,6 +954,7 @@ var rpcResultTypes = map[string][]interface{}{ "getblockcount": {(*int64)(nil)}, "getblockhash": {(*string)(nil)}, "getblockheader": {(*string)(nil), (*btcjson.GetBlockHeaderVerboseResult)(nil)}, + "getblockstats": {(*btcjson.GetBlockStatsResult)(nil)}, "getblocktemplate": {(*btcjson.GetBlockTemplateResult)(nil), (*string)(nil), nil}, "getcfilter": {(*string)(nil)}, "getcfilterheader": {(*string)(nil)},