Merge pull request #337 from trezor/balanceHistory

Add API for account balance history #307
pull/340/head
Martin 2019-12-18 00:05:34 +01:00 committed by GitHub
commit 1ce7bc1406
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 878 additions and 120 deletions

View File

@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"math/big"
"sort"
"time"
)
@ -296,6 +297,69 @@ func (a Utxos) Less(i, j int) bool {
return hi >= hj
}
// BalanceHistory contains info about one point in time of balance history
type BalanceHistory struct {
Time uint32 `json:"time"`
Txs uint32 `json:"txs"`
ReceivedSat *Amount `json:"received"`
SentSat *Amount `json:"sent"`
FiatRate string `json:"fiatRate,omitempty"`
Txid string `json:"txid,omitempty"`
}
// BalanceHistories is array of BalanceHistory
type BalanceHistories []BalanceHistory
func (a BalanceHistories) Len() int { return len(a) }
func (a BalanceHistories) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a BalanceHistories) Less(i, j int) bool {
ti := a[i].Time
tj := a[j].Time
if ti == tj {
return a[i].Txid < a[j].Txid
}
return ti < tj
}
// SortAndAggregate sums BalanceHistories to groups defined by parameter groupByTime
func (a BalanceHistories) SortAndAggregate(groupByTime uint32) BalanceHistories {
bhs := make(BalanceHistories, 0)
if len(a) > 0 {
bha := BalanceHistory{
SentSat: &Amount{},
ReceivedSat: &Amount{},
}
sort.Sort(a)
for i := range a {
bh := &a[i]
time := bh.Time - bh.Time%groupByTime
if bha.Time != time {
if bha.Time != 0 {
// in aggregate, do not return txid as it could multiple of them
bha.Txid = ""
bhs = append(bhs, bha)
}
bha = BalanceHistory{
Time: time,
SentSat: &Amount{},
ReceivedSat: &Amount{},
}
}
if bha.Txid != bh.Txid {
bha.Txs += bh.Txs
bha.Txid = bh.Txid
}
(*big.Int)(bha.SentSat).Add((*big.Int)(bha.SentSat), (*big.Int)(bh.SentSat))
(*big.Int)(bha.ReceivedSat).Add((*big.Int)(bha.ReceivedSat), (*big.Int)(bh.ReceivedSat))
}
if bha.Txs > 0 {
bha.Txid = ""
bhs = append(bhs, bha)
}
}
return bhs
}
// Blocks is list of blocks with paging information
type Blocks struct {
Paging

View File

@ -49,3 +49,115 @@ func TestAmount_MarshalJSON(t *testing.T) {
})
}
}
func TestBalanceHistories_SortAndAggregate(t *testing.T) {
tests := []struct {
name string
a BalanceHistories
groupByTime uint32
want BalanceHistories
}{
{
name: "empty",
a: []BalanceHistory{},
groupByTime: 3600,
want: []BalanceHistory{},
},
{
name: "one",
a: []BalanceHistory{
{
ReceivedSat: (*Amount)(big.NewInt(1)),
SentSat: (*Amount)(big.NewInt(2)),
Time: 1521514812,
Txid: "00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa3840",
Txs: 1,
},
},
groupByTime: 3600,
want: []BalanceHistory{
{
ReceivedSat: (*Amount)(big.NewInt(1)),
SentSat: (*Amount)(big.NewInt(2)),
Time: 1521514800,
Txs: 1,
},
},
},
{
name: "aggregate",
a: []BalanceHistory{
{
ReceivedSat: (*Amount)(big.NewInt(1)),
SentSat: (*Amount)(big.NewInt(2)),
Time: 1521504812,
Txid: "0011223344556677889900112233445566778899001122334455667788990011",
Txs: 1,
},
{
ReceivedSat: (*Amount)(big.NewInt(3)),
SentSat: (*Amount)(big.NewInt(4)),
Time: 1521504812,
Txid: "00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa3840",
Txs: 1,
},
{
ReceivedSat: (*Amount)(big.NewInt(5)),
SentSat: (*Amount)(big.NewInt(6)),
Time: 1521514812,
Txid: "00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa3840",
Txs: 1,
},
{
ReceivedSat: (*Amount)(big.NewInt(7)),
SentSat: (*Amount)(big.NewInt(8)),
Time: 1521504812,
Txid: "00b2c06055e5e90e9c82bd4181fde310104391a7fa4f289b1704e5d90caa3840",
Txs: 1,
},
{
ReceivedSat: (*Amount)(big.NewInt(9)),
SentSat: (*Amount)(big.NewInt(10)),
Time: 1521534812,
Txid: "0011223344556677889900112233445566778899001122334455667788990011",
Txs: 1,
},
{
ReceivedSat: (*Amount)(big.NewInt(11)),
SentSat: (*Amount)(big.NewInt(12)),
Time: 1521534812,
Txid: "1122334455667788990011223344556677889900112233445566778899001100",
Txs: 1,
},
},
groupByTime: 3600,
want: []BalanceHistory{
{
ReceivedSat: (*Amount)(big.NewInt(11)),
SentSat: (*Amount)(big.NewInt(14)),
Time: 1521504000,
Txs: 2,
},
{
ReceivedSat: (*Amount)(big.NewInt(5)),
SentSat: (*Amount)(big.NewInt(6)),
Time: 1521514800,
Txs: 1,
},
{
ReceivedSat: (*Amount)(big.NewInt(20)),
SentSat: (*Amount)(big.NewInt(22)),
Time: 1521532800,
Txs: 2,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.a.SortAndAggregate(tt.groupByTime); !reflect.DeepEqual(got, tt.want) {
t.Errorf("BalanceHistories.SortAndAggregate() = %+v, want %+v", got, tt.want)
}
})
}
}

View File

@ -802,6 +802,172 @@ func (w *Worker) GetAddress(address string, page int, txsOnPage int, option Acco
return r, nil
}
func (w *Worker) balanceHistoryHeightsFromTo(fromTime, toTime time.Time) (uint32, uint32, uint32, uint32) {
fromUnix := uint32(0)
toUnix := maxUint32
fromHeight := uint32(0)
toHeight := maxUint32
if !fromTime.IsZero() {
fromUnix = uint32(fromTime.Unix())
fromHeight = w.is.GetBlockHeightOfTime(fromUnix)
}
if !toTime.IsZero() {
toUnix = uint32(toTime.Unix())
toHeight = w.is.GetBlockHeightOfTime(toUnix)
}
return fromUnix, fromHeight, toUnix, toHeight
}
func (w *Worker) balanceHistoryForTxid(addrDesc bchain.AddressDescriptor, txid string, fromUnix, toUnix uint32) (*BalanceHistory, error) {
var time uint32
var err error
var ta *db.TxAddresses
var bchainTx *bchain.Tx
var height uint32
if w.chainType == bchain.ChainBitcoinType {
ta, err = w.db.GetTxAddresses(txid)
if err != nil {
return nil, err
}
if ta == nil {
glog.Warning("DB inconsistency: tx ", txid, ": not found in txAddresses")
return nil, nil
}
height = ta.Height
} else if w.chainType == bchain.ChainEthereumType {
var h int
bchainTx, h, err = w.txCache.GetTransaction(txid)
if err != nil {
return nil, err
}
if bchainTx == nil {
glog.Warning("Inconsistency: tx ", txid, ": not found in the blockchain")
return nil, nil
}
height = uint32(h)
}
time = w.is.GetBlockTime(height)
if time < fromUnix || time >= toUnix {
return nil, nil
}
bh := BalanceHistory{
Time: time,
Txs: 1,
SentSat: &Amount{},
ReceivedSat: &Amount{},
Txid: txid,
}
if w.chainType == bchain.ChainBitcoinType {
for i := range ta.Inputs {
tai := &ta.Inputs[i]
if bytes.Equal(addrDesc, tai.AddrDesc) {
(*big.Int)(bh.SentSat).Add((*big.Int)(bh.SentSat), &tai.ValueSat)
}
}
for i := range ta.Outputs {
tao := &ta.Outputs[i]
if bytes.Equal(addrDesc, tao.AddrDesc) {
(*big.Int)(bh.ReceivedSat).Add((*big.Int)(bh.ReceivedSat), &tao.ValueSat)
}
}
} else if w.chainType == bchain.ChainEthereumType {
var value big.Int
ethTxData := eth.GetEthereumTxData(bchainTx)
// add received amount only for OK transactions
if ethTxData.Status == 1 {
if len(bchainTx.Vout) > 0 {
bchainVout := &bchainTx.Vout[0]
value = bchainVout.ValueSat
if len(bchainVout.ScriptPubKey.Addresses) > 0 {
txAddrDesc, err := w.chainParser.GetAddrDescFromAddress(bchainVout.ScriptPubKey.Addresses[0])
if err != nil {
return nil, err
}
if bytes.Equal(addrDesc, txAddrDesc) {
(*big.Int)(bh.ReceivedSat).Add((*big.Int)(bh.ReceivedSat), &value)
}
}
}
}
for i := range bchainTx.Vin {
bchainVin := &bchainTx.Vin[i]
if len(bchainVin.Addresses) > 0 {
txAddrDesc, err := w.chainParser.GetAddrDescFromAddress(bchainVin.Addresses[0])
if err != nil {
return nil, err
}
if bytes.Equal(addrDesc, txAddrDesc) {
// add sent amount only for OK transactions, however fees always
if ethTxData.Status == 1 {
(*big.Int)(bh.SentSat).Add((*big.Int)(bh.SentSat), &value)
}
var feesSat big.Int
// mempool txs do not have fees yet
if ethTxData.GasUsed != nil {
feesSat.Mul(ethTxData.GasPrice, ethTxData.GasUsed)
}
(*big.Int)(bh.SentSat).Add((*big.Int)(bh.SentSat), &feesSat)
}
}
}
}
return &bh, nil
}
func (w *Worker) setFiatRateToBalanceHistories(histories BalanceHistories, fiat string) error {
for i := range histories {
bh := &histories[i]
t := time.Unix(int64(bh.Time), 0)
ticker, err := w.db.FiatRatesFindTicker(&t)
if err != nil {
glog.Errorf("Error finding ticker by date %v. Error: %v", t, err)
continue
} else if ticker == nil {
continue
}
if rate, found := ticker.Rates[fiat]; found {
bh.FiatRate = string(rate)
}
}
return nil
}
// GetBalanceHistory returns history of balance for given address
func (w *Worker) GetBalanceHistory(address string, fromTime, toTime time.Time, fiat string) (BalanceHistories, error) {
bhs := make(BalanceHistories, 0)
start := time.Now()
addrDesc, _, err := w.getAddrDescAndNormalizeAddress(address)
if err != nil {
return nil, err
}
fromUnix, fromHeight, toUnix, toHeight := w.balanceHistoryHeightsFromTo(fromTime, toTime)
if fromHeight >= toHeight {
return bhs, nil
}
txs, err := w.getAddressTxids(addrDesc, false, &AddressFilter{Vout: AddressFilterVoutOff, FromHeight: fromHeight, ToHeight: toHeight}, maxInt)
if err != nil {
return nil, err
}
for txi := len(txs) - 1; txi >= 0; txi-- {
bh, err := w.balanceHistoryForTxid(addrDesc, txs[txi], fromUnix, toUnix)
if err != nil {
return nil, err
}
if bh != nil {
bhs = append(bhs, *bh)
}
}
bha := bhs.SortAndAggregate(3600)
if fiat != "" {
err = w.setFiatRateToBalanceHistories(bha, fiat)
if err != nil {
return nil, err
}
}
glog.Info("GetBalanceHistory ", address, ", blocks ", fromHeight, "-", toHeight, ", count ", len(bha), " finished in ", time.Since(start))
return bha, nil
}
func (w *Worker) waitForBackendSync() {
// wait a short time if blockbook is synchronizing with backend
inSync, _, _ := w.is.GetSyncState()
@ -975,29 +1141,27 @@ func (w *Worker) GetBlocks(page int, blocksOnPage int) (*Blocks, error) {
// getFiatRatesResult checks if CurrencyRatesTicker contains all necessary data and returns formatted result
func (w *Worker) getFiatRatesResult(currency string, ticker *db.CurrencyRatesTicker) (*db.ResultTickerAsString, error) {
resultRates := make(map[string]json.Number)
rates := make(map[string]json.Number, 2)
timeFormatted := ticker.Timestamp.Format(db.FiatRatesTimeFormat)
// Check if both USD rate and the desired currency rate exist in the result
for _, currencySymbol := range []string{"usd", currency} {
if _, found := ticker.Rates[currencySymbol]; !found {
availableCurrencies := make([]string, 0, len(ticker.Rates))
for availableCurrency := range ticker.Rates {
availableCurrencies = append(availableCurrencies, string(availableCurrency))
}
sort.Strings(availableCurrencies) // sort to get deterministic results
availableCurrenciesString := strings.Join(availableCurrencies, ", ")
return nil, NewAPIError(fmt.Sprintf("Currency %q is not available for timestamp %s. Available currencies are: %s.", currency, timeFormatted, availableCurrenciesString), true)
if rate, found := ticker.Rates[currency]; !found {
availableCurrencies := make([]string, 0, len(ticker.Rates))
for availableCurrency := range ticker.Rates {
availableCurrencies = append(availableCurrencies, availableCurrency)
}
resultRates[currencySymbol] = ticker.Rates[currencySymbol]
if currencySymbol == "usd" && currency == "usd" {
break
sort.Strings(availableCurrencies) // sort to get deterministic results
return nil, NewAPIError(fmt.Sprintf("Currency %q is not available for timestamp %s. Available currencies are: %s", currency, timeFormatted, strings.Join(availableCurrencies, ",")), true)
} else {
rates[currency] = rate
}
// add default usd currency
if currency != "usd" {
if rate, found := ticker.Rates["usd"]; found {
rates["usd"] = rate
}
}
result := &db.ResultTickerAsString{
Timestamp: timeFormatted,
Rates: resultRates,
Rates: rates,
}
return result, nil
}
@ -1007,7 +1171,7 @@ func (w *Worker) GetFiatRatesForBlockID(bid string, currency string) (*db.Result
if currency == "" {
return nil, NewAPIError("Missing or empty \"currency\" parameter", true)
}
ticker := &db.CurrencyRatesTicker{}
var ticker *db.CurrencyRatesTicker
bi, err := w.getBlockInfoFromBlockID(bid)
if err != nil {
if err == bchain.ErrBlockNotFound {

View File

@ -589,3 +589,46 @@ func (w *Worker) GetXpubUtxo(xpub string, onlyConfirmed bool, gap int) (Utxos, e
glog.Info("GetXpubUtxo ", xpub[:16], ", ", len(r), " utxos, finished in ", time.Since(start))
return r, nil
}
// GetXpubBalanceHistory returns history of balance for given xpub
func (w *Worker) GetXpubBalanceHistory(xpub string, fromTime, toTime time.Time, fiat string, gap int) (BalanceHistories, error) {
bhs := make(BalanceHistories, 0)
start := time.Now()
fromUnix, fromHeight, toUnix, toHeight := w.balanceHistoryHeightsFromTo(fromTime, toTime)
if fromHeight >= toHeight {
return bhs, nil
}
data, _, err := w.getXpubData(xpub, 0, 1, AccountDetailsTxidHistory, &AddressFilter{
Vout: AddressFilterVoutOff,
OnlyConfirmed: true,
FromHeight: fromHeight,
ToHeight: toHeight,
}, gap)
if err != nil {
return nil, err
}
for _, da := range [][]xpubAddress{data.addresses, data.changeAddresses} {
for i := range da {
ad := &da[i]
txids := ad.txids
for txi := len(txids) - 1; txi >= 0; txi-- {
bh, err := w.balanceHistoryForTxid(ad.addrDesc, txids[txi].txid, fromUnix, toUnix)
if err != nil {
return nil, err
}
if bh != nil {
bhs = append(bhs, *bh)
}
}
}
}
bha := bhs.SortAndAggregate(3600)
if fiat != "" {
err = w.setFiatRateToBalanceHistories(bha, fiat)
if err != nil {
return nil, err
}
}
glog.Info("GetUtxoBalanceHistory ", xpub[:16], ", blocks ", fromHeight, "-", toHeight, ", count ", len(bha), ", finished in ", time.Since(start))
return bha, nil
}

View File

@ -74,9 +74,9 @@ func erc20GetTransfersFromLog(logs []*rpcLog) ([]bchain.Erc20Transfer, error) {
return nil, err
}
r = append(r, bchain.Erc20Transfer{
Contract: strings.ToLower(l.Address),
From: strings.ToLower(from),
To: strings.ToLower(to),
Contract: EIP55AddressFromAddress(l.Address),
From: EIP55AddressFromAddress(from),
To: EIP55AddressFromAddress(to),
Tokens: t,
})
}
@ -97,9 +97,9 @@ func erc20GetTransfersFromTx(tx *rpcTransaction) ([]bchain.Erc20Transfer, error)
return nil, errors.New("Data is not a number")
}
r = append(r, bchain.Erc20Transfer{
Contract: strings.ToLower(tx.To),
From: strings.ToLower(tx.From),
To: strings.ToLower(to),
Contract: EIP55AddressFromAddress(tx.To),
From: EIP55AddressFromAddress(tx.From),
To: EIP55AddressFromAddress(to),
Tokens: t,
})
}

View File

@ -202,6 +202,15 @@ func EIP55Address(addrDesc bchain.AddressDescriptor) string {
return string(result)
}
// EIP55AddressFromAddress returns an EIP55-compliant hex string representation of the address
func EIP55AddressFromAddress(address string) string {
b, err := hex.DecodeString(address)
if err != nil {
return address
}
return EIP55Address(b)
}
// GetAddressesFromAddrDesc returns addresses for given address descriptor with flag if the addresses are searchable
func (p *EthereumParser) GetAddressesFromAddrDesc(addrDesc bchain.AddressDescriptor) ([]string, bool, error) {
return []string{EIP55Address(addrDesc)}, true, nil

View File

@ -2,6 +2,7 @@ package common
import (
"encoding/json"
"sort"
"sync"
"time"
)
@ -45,6 +46,7 @@ type InternalState struct {
IsSynchronized bool `json:"isSynchronized"`
BestHeight uint32 `json:"bestHeight"`
LastSync time.Time `json:"lastSync"`
BlockTimes []uint32 `json:"-"`
IsMempoolSynchronized bool `json:"isMempoolSynchronized"`
MempoolSize int `json:"mempoolSize"`
@ -164,6 +166,54 @@ func (is *InternalState) DBSizeTotal() int64 {
return total
}
// GetBlockTime returns block time if block found or 0
func (is *InternalState) GetBlockTime(height uint32) uint32 {
is.mux.Lock()
defer is.mux.Unlock()
if int(height) < len(is.BlockTimes) {
return is.BlockTimes[height]
}
return 0
}
// AppendBlockTime appends block time to BlockTimes
func (is *InternalState) AppendBlockTime(time uint32) {
is.mux.Lock()
defer is.mux.Unlock()
is.BlockTimes = append(is.BlockTimes, time)
}
// RemoveLastBlockTimes removes last times from BlockTimes
func (is *InternalState) RemoveLastBlockTimes(count int) {
is.mux.Lock()
defer is.mux.Unlock()
if len(is.BlockTimes) < count {
count = len(is.BlockTimes)
}
is.BlockTimes = is.BlockTimes[:len(is.BlockTimes)-count]
}
// GetBlockHeightOfTime returns block height of the first block with time greater or equal to the given time or MaxUint32 if no such block
func (is *InternalState) GetBlockHeightOfTime(time uint32) uint32 {
is.mux.Lock()
defer is.mux.Unlock()
height := sort.Search(len(is.BlockTimes), func(i int) bool { return time <= is.BlockTimes[i] })
if height == len(is.BlockTimes) {
return ^uint32(0)
}
// as the block times can sometimes be out of order try 20 blocks lower to locate a block with the time greater or equal to the given time
max, height := height, height-20
if height < 0 {
height = 0
}
for ; height <= max; height++ {
if time <= is.BlockTimes[height] {
break
}
}
return uint32(height)
}
// Pack marshals internal state to json
func (is *InternalState) Pack() ([]byte, error) {
is.mux.Lock()

View File

@ -381,6 +381,12 @@ func (b *BulkConnect) Close() error {
return err
}
}
var err error
b.d.is.BlockTimes, err = b.d.loadBlockTimes()
if err != nil {
return err
}
if err := b.d.SetInconsistentState(false); err != nil {
return err
}

View File

@ -476,8 +476,11 @@ func (d *RocksDB) ConnectBlock(block *bchain.Block) error {
if err := d.storeAddresses(wb, block.Height, addresses); err != nil {
return err
}
return d.db.Write(d.wo, wb)
if err := d.db.Write(d.wo, wb); err != nil {
return err
}
d.is.AppendBlockTime(uint32(block.Time))
return nil
}
// Addresses index
@ -1462,6 +1465,7 @@ func (d *RocksDB) DisconnectBlockRangeBitcoinType(lower uint32, higher uint32) e
}
err := d.db.Write(d.wo, wb)
if err == nil {
d.is.RemoveLastBlockTimes(int(higher-lower) + 1)
glog.Infof("rocksdb: blocks %d-%d disconnected", lower, higher)
}
return err
@ -1576,6 +1580,32 @@ func (d *RocksDB) internalDeleteTx(wb *gorocksdb.WriteBatch, key []byte) {
// internal state
const internalStateKey = "internalState"
func (d *RocksDB) loadBlockTimes() ([]uint32, error) {
var times []uint32
it := d.db.NewIteratorCF(d.ro, d.cfh[cfHeight])
defer it.Close()
counter := uint32(0)
time := uint32(0)
for it.SeekToFirst(); it.Valid(); it.Next() {
height := unpackUint(it.Key().Data())
if height > counter {
glog.Warning("gap in cfHeight: expecting ", counter, ", got ", height)
for ; counter < height; counter++ {
times = append(times, time)
}
}
counter++
info, err := d.unpackBlockInfo(it.Value().Data())
if err != nil {
return nil, err
}
time = uint32(info.Time)
times = append(times, time)
}
glog.Info("loaded ", len(times), " block times")
return times, nil
}
// LoadInternalState loads from db internal state or initializes a new one if not yet stored
func (d *RocksDB) LoadInternalState(rpcCoin string) (*common.InternalState, error) {
val, err := d.db.GetCF(d.ro, d.cfh[cfDefault], []byte(internalStateKey))
@ -1621,6 +1651,10 @@ func (d *RocksDB) LoadInternalState(rpcCoin string) (*common.InternalState, erro
}
}
is.DbColumns = nc
is.BlockTimes, err = d.loadBlockTimes()
if err != nil {
return nil, err
}
// after load, reset the synchronization data
is.IsSynchronized = false
is.IsMempoolSynchronized = false

View File

@ -455,6 +455,7 @@ func (d *RocksDB) DisconnectBlockRangeEthereumType(lower uint32, higher uint32)
d.storeAddressContracts(wb, contracts)
err := d.db.Write(d.wo, wb)
if err == nil {
d.is.RemoveLastBlockTimes(int(higher-lower) + 1)
glog.Infof("rocksdb: blocks %d-%d disconnected", lower, higher)
}
return err

View File

@ -167,6 +167,10 @@ func TestRocksDB_Index_EthereumType(t *testing.T) {
})
defer closeAndDestroyRocksDB(t, d)
if len(d.is.BlockTimes) != 0 {
t.Fatal("Expecting is.BlockTimes 0, got ", len(d.is.BlockTimes))
}
// connect 1st block
block1 := dbtestdata.GetTestEthereumTypeBlock1(d.chainParser)
if err := d.ConnectBlock(block1); err != nil {
@ -174,6 +178,10 @@ func TestRocksDB_Index_EthereumType(t *testing.T) {
}
verifyAfterEthereumTypeBlock1(t, d, false)
if len(d.is.BlockTimes) != 1 {
t.Fatal("Expecting is.BlockTimes 1, got ", len(d.is.BlockTimes))
}
// connect 2nd block
block2 := dbtestdata.GetTestEthereumTypeBlock2(d.chainParser)
if err := d.ConnectBlock(block2); err != nil {
@ -181,6 +189,10 @@ func TestRocksDB_Index_EthereumType(t *testing.T) {
}
verifyAfterEthereumTypeBlock2(t, d)
if len(d.is.BlockTimes) != 2 {
t.Fatal("Expecting is.BlockTimes 2, got ", len(d.is.BlockTimes))
}
// get transactions for various addresses / low-high ranges
verifyGetTransactions(t, d, "0x"+dbtestdata.EthAddr55, 0, 10000000, []txidIndex{
{"0x" + dbtestdata.EthTxidB2T2, ^2},
@ -275,10 +287,18 @@ func TestRocksDB_Index_EthereumType(t *testing.T) {
}
}
if len(d.is.BlockTimes) != 1 {
t.Fatal("Expecting is.BlockTimes 1, got ", len(d.is.BlockTimes))
}
// connect block again and verify the state of db
if err := d.ConnectBlock(block2); err != nil {
t.Fatal(err)
}
verifyAfterEthereumTypeBlock2(t, d)
if len(d.is.BlockTimes) != 2 {
t.Fatal("Expecting is.BlockTimes 2, got ", len(d.is.BlockTimes))
}
}

View File

@ -182,7 +182,7 @@ func verifyAfterBitcoinTypeBlock1(t *testing.T, d *RocksDB, afterDisconnect bool
if err := checkColumn(d, cfHeight, []keyPair{
{
"000370d5",
"0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997" + uintToHex(1534858021) + varuintToHex(2) + varuintToHex(1234567),
"0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997" + uintToHex(1521515026) + varuintToHex(2) + varuintToHex(1234567),
nil,
},
}); err != nil {
@ -288,12 +288,12 @@ func verifyAfterBitcoinTypeBlock2(t *testing.T, d *RocksDB) {
if err := checkColumn(d, cfHeight, []keyPair{
{
"000370d5",
"0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997" + uintToHex(1534858021) + varuintToHex(2) + varuintToHex(1234567),
"0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997" + uintToHex(1521515026) + varuintToHex(2) + varuintToHex(1234567),
nil,
},
{
"000370d6",
"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6" + uintToHex(1534859123) + varuintToHex(4) + varuintToHex(2345678),
"00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6" + uintToHex(1521595678) + varuintToHex(4) + varuintToHex(2345678),
nil,
},
}); err != nil {
@ -534,6 +534,10 @@ func TestRocksDB_Index_BitcoinType(t *testing.T) {
})
defer closeAndDestroyRocksDB(t, d)
if len(d.is.BlockTimes) != 0 {
t.Fatal("Expecting is.BlockTimes 0, got ", len(d.is.BlockTimes))
}
// connect 1st block - will log warnings about missing UTXO transactions in txAddresses column
block1 := dbtestdata.GetTestBitcoinTypeBlock1(d.chainParser)
if err := d.ConnectBlock(block1); err != nil {
@ -541,6 +545,10 @@ func TestRocksDB_Index_BitcoinType(t *testing.T) {
}
verifyAfterBitcoinTypeBlock1(t, d, false)
if len(d.is.BlockTimes) != 1 {
t.Fatal("Expecting is.BlockTimes 1, got ", len(d.is.BlockTimes))
}
// connect 2nd block - use some outputs from the 1st block as the inputs and 1 input uses tx from the same block
block2 := dbtestdata.GetTestBitcoinTypeBlock2(d.chainParser)
if err := d.ConnectBlock(block2); err != nil {
@ -548,6 +556,10 @@ func TestRocksDB_Index_BitcoinType(t *testing.T) {
}
verifyAfterBitcoinTypeBlock2(t, d)
if len(d.is.BlockTimes) != 2 {
t.Fatal("Expecting is.BlockTimes 1, got ", len(d.is.BlockTimes))
}
// get transactions for various addresses / low-high ranges
verifyGetTransactions(t, d, dbtestdata.Addr2, 0, 1000000, []txidIndex{
{dbtestdata.TxidB2T1, ^1},
@ -608,7 +620,7 @@ func TestRocksDB_Index_BitcoinType(t *testing.T) {
Hash: "00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6",
Txs: 4,
Size: 2345678,
Time: 1534859123,
Time: 1521595678,
Height: 225494,
}
if !reflect.DeepEqual(info, iw) {
@ -651,12 +663,20 @@ func TestRocksDB_Index_BitcoinType(t *testing.T) {
}
}
if len(d.is.BlockTimes) != 1 {
t.Fatal("Expecting is.BlockTimes 1, got ", len(d.is.BlockTimes))
}
// connect block again and verify the state of db
if err := d.ConnectBlock(block2); err != nil {
t.Fatal(err)
}
verifyAfterBitcoinTypeBlock2(t, d)
if len(d.is.BlockTimes) != 2 {
t.Fatal("Expecting is.BlockTimes 1, got ", len(d.is.BlockTimes))
}
// test public methods for address balance and tx addresses
ab, err := d.GetAddressBalance(dbtestdata.Addr5, AddressBalanceDetailUTXO)
if err != nil {
@ -746,6 +766,10 @@ func Test_BulkConnect_BitcoinType(t *testing.T) {
t.Fatal("DB not in DbStateInconsistent")
}
if len(d.is.BlockTimes) != 0 {
t.Fatal("Expecting is.BlockTimes 0, got ", len(d.is.BlockTimes))
}
if err := bc.ConnectBlock(dbtestdata.GetTestBitcoinTypeBlock1(d.chainParser), false); err != nil {
t.Fatal(err)
}
@ -768,6 +792,10 @@ func Test_BulkConnect_BitcoinType(t *testing.T) {
}
verifyAfterBitcoinTypeBlock2(t, d)
if len(d.is.BlockTimes) != 225495 {
t.Fatal("Expecting is.BlockTimes 225495, got ", len(d.is.BlockTimes))
}
}
func Test_packBigint_unpackBigint(t *testing.T) {

View File

@ -175,6 +175,7 @@ func (s *PublicServer) ConnectFullPublicInterface() {
serveMux.HandleFunc(path+"api/block/", s.jsonHandler(s.apiBlock, apiDefault))
serveMux.HandleFunc(path+"api/sendtx/", s.jsonHandler(s.apiSendTx, apiDefault))
serveMux.HandleFunc(path+"api/estimatefee/", s.jsonHandler(s.apiEstimateFee, apiDefault))
serveMux.HandleFunc(path+"api/balancehistory/", s.jsonHandler(s.apiBalanceHistory, apiDefault))
// v2 format
serveMux.HandleFunc(path+"api/v2/block-index/", s.jsonHandler(s.apiBlockIndex, apiV2))
serveMux.HandleFunc(path+"api/v2/tx-specific/", s.jsonHandler(s.apiTxSpecific, apiV2))
@ -186,6 +187,7 @@ func (s *PublicServer) ConnectFullPublicInterface() {
serveMux.HandleFunc(path+"api/v2/sendtx/", s.jsonHandler(s.apiSendTx, apiV2))
serveMux.HandleFunc(path+"api/v2/estimatefee/", s.jsonHandler(s.apiEstimateFee, apiV2))
serveMux.HandleFunc(path+"api/v2/feestats/", s.jsonHandler(s.apiFeeStats, apiV2))
serveMux.HandleFunc(path+"api/v2/balancehistory/", s.jsonHandler(s.apiBalanceHistory, apiDefault))
serveMux.HandleFunc(path+"api/v2/tickers/", s.jsonHandler(s.apiTickers, apiV2))
serveMux.HandleFunc(path+"api/v2/tickers-list/", s.jsonHandler(s.apiTickersList, apiV2))
// socket.io interface
@ -1038,6 +1040,36 @@ func (s *PublicServer) apiUtxo(r *http.Request, apiVersion int) (interface{}, er
return utxo, err
}
func (s *PublicServer) apiBalanceHistory(r *http.Request, apiVersion int) (interface{}, error) {
var history []api.BalanceHistory
var fromTime, toTime time.Time
var err error
if i := strings.LastIndexByte(r.URL.Path, '/'); i > 0 {
gap, ec := strconv.Atoi(r.URL.Query().Get("gap"))
if ec != nil {
gap = 0
}
t := r.URL.Query().Get("from")
if t != "" {
fromTime, _ = time.Parse("2006-01-02", t)
}
t = r.URL.Query().Get("to")
if t != "" {
// time.RFC3339
toTime, _ = time.Parse("2006-01-02", t)
}
fiat := r.URL.Query().Get("fiatcurrency")
history, err = s.api.GetXpubBalanceHistory(r.URL.Path[i+1:], fromTime, toTime, fiat, gap)
if err == nil {
s.metrics.ExplorerViews.With(common.Labels{"action": "api-xpub-balancehistory"}).Inc()
} else {
history, err = s.api.GetBalanceHistory(r.URL.Path[i+1:], fromTime, toTime, fiat)
s.metrics.ExplorerViews.With(common.Labels{"action": "api-address-balancehistory"}).Inc()
}
}
return history, err
}
func (s *PublicServer) apiBlock(r *http.Request, apiVersion int) (interface{}, error) {
var block *api.Block
var err error

File diff suppressed because one or more lines are too long

View File

@ -253,6 +253,36 @@ var requestHandlers = map[string]func(*WebsocketServer, *websocketChannel, *webs
}
return
},
"getBalanceHistory": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) {
r := struct {
Descriptor string `json:"descriptor"`
From string `json:"from"`
To string `json:"to"`
Fiat string `json:"fiat"`
Gap int `json:"gap"`
}{}
err = json.Unmarshal(req.Params, &r)
if err == nil {
var fromTime, toTime time.Time
if r.From != "" {
fromTime, err = time.Parse("2006-01-02", r.From)
if err != nil {
return
}
}
if r.To != "" {
toTime, err = time.Parse("2006-01-02", r.To)
if err != nil {
return
}
}
rv, err = s.api.GetXpubBalanceHistory(r.Descriptor, fromTime, toTime, r.Fiat, r.Gap)
if err != nil {
rv, err = s.api.GetBalanceHistory(r.Descriptor, fromTime, toTime, r.Fiat)
}
}
return
},
"getTransaction": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) {
r := struct {
Txid string `json:"txid"`

View File

@ -161,6 +161,25 @@
});
}
function getBalanceHistory() {
const descriptor = document.getElementById('getBalanceHistoryDescriptor').value.trim();
const from = document.getElementById("getBalanceHistoryFrom").value.trim();
const to = document.getElementById("getBalanceHistoryTo").value.trim();
const fiat = document.getElementById("getBalanceHistoryFiat").value.trim();
const method = 'getBalanceHistory';
const params = {
descriptor,
from,
to,
fiat
// default gap=20
};
send(method, params, function (result) {
document.getElementById('getBalanceHistoryResult').innerText = JSON.stringify(result).replace(/,/g, ", ");
});
}
function getTransaction() {
const txid = document.getElementById('getTransactionTxid').value.trim();
const method = 'getTransaction';
@ -431,6 +450,26 @@
<div class="col" id="getAccountUtxoResult">
</div>
</div>
<div class="row">
<div class="col">
<input class="btn btn-secondary" type="button" value="getBalanceHistory" onclick="getBalanceHistory()">
</div>
<div class="col-8">
<div class="row" style="margin: 0;">
<input type="text" placeholder="descriptor" class="form-control" id="getBalanceHistoryDescriptor" value="0xba98d6a5ac827632e3457de7512d211e4ff7e8bd">
</div>
<div class="row" style="margin: 0; margin-top: 5px;">
<input type="text" placeholder="from YYYY-MM-DD" style="width: 30%;margin-left: 5px;margin-right: 5px;" class="form-control" id="getBalanceHistoryFrom">
<input type="text" placeholder="to YYYY-MM-DD" style="width: 30%; margin-left: 5px; margin-right: 5px;" class="form-control" id="getBalanceHistoryTo">
<input type="text" placeholder="fiat" style="width: 20%; margin-left: 5px; margin-right: 5px;" class="form-control" id="getBalanceHistoryFiat">
</div>
</div>
<div class="col form-inline"></div>
</div>
<div class="row">
<div class="col" id="getBalanceHistoryResult">
</div>
</div>
<div class="row">
<div class="col">
<input class="btn btn-secondary" type="button" value="getTransaction" onclick="getTransaction()">
@ -492,6 +531,42 @@
<div class="row">
<div class="col" id="sendTransactionResult"></div>
</div>
<div class="row">
<div class="col-2">
<input class="btn btn-secondary" type="button" value="get fiat rates for dates" onclick="getFiatRatesForDates()">
</div>
<div class="col-1">
<input type="text" class="form-control" id="getFiatRatesForDatesCurrency" value="usd">
</div>
<div class="col-7">
<input type="text" class="form-control" id="getFiatRatesForDatesList" value="20191121140000,20191121143015">
</div>
</div>
<div class="row">
<div class="col" id="getFiatRatesForDatesResult"></div>
</div>
<div class="row">
<div class="col-2">
<input class="btn btn-secondary" type="button" value="get current fiat rates" onclick="getCurrentFiatRates()">
</div>
<div class="col-1">
<input type="text" class="form-control" id="getCurrentFiatRatesCurrency" value="usd">
</div>
</div>
<div class="row">
<div class="col" id="getCurrentFiatRatesResult"></div>
</div>
<div class="row">
<div class="col-2">
<input class="btn btn-secondary" type="button" value="get fiat rates tickers" onclick="getFiatRatesTickersList()">
</div>
<div class="col-8">
<input type="text" class="form-control" id="getFiatRatesTickersListDate" value="20191121140000">
</div>
</div>
<div class="row">
<div class="col" id="getFiatRatesTickersListResult"></div>
</div>
<div class="row">
<div class="col">
<input class="btn btn-secondary" type="button" value="subscribe new block" onclick="subscribeNewBlock()">
@ -524,52 +599,16 @@
<div class="col" id="subscribeAddressesResult"></div>
</div>
<div class="row">
<div class="col">
<input class="btn btn-secondary" type="button" value="get fiat rates for dates" onclick="getFiatRatesForDates()">
</div>
<div class="col-8">
<input type="text" class="form-control" id="getFiatRatesForDatesCurrency" value="usd">
</div>
<div class="col-8">
<input type="text" class="form-control" id="getFiatRatesForDatesList" value="20191121140000,20191121143015">
</div>
</div>
<div class="row">
<div class="col" id="getFiatRatesForDatesResult"></div>
</div>
<div class="row">
<div class="col">
<input class="btn btn-secondary" type="button" value="get current firat rates" onclick="getCurrentFiatRates()">
</div>
<div class="col-8">
<input type="text" class="form-control" id="getCurrentFiatRatesCurrency" value="usd">
</div>
</div>
<div class="row">
<div class="col" id="getCurrentFiatRatesResult"></div>
</div>
<div class="row">
<div class="col">
<input class="btn btn-secondary" type="button" value="get fiat rates tickers" onclick="getFiatRatesTickersList()">
</div>
<div class="col-8">
<input type="text" class="form-control" id="getFiatRatesTickersListDate" value="20191121140000">
</div>
</div>
<div class="row">
<div class="col" id="getFiatRatesTickersListResult"></div>
</div>
<div class="row">
<div class="col">
<div class="col-3">
<input class="btn btn-secondary" type="button" value="subscribe new fiat rates" onclick="subscribeNewFiatRatesTicker()">
</div>
<div class="col-4">
<div class="col-1">
<span id="subscribeNewFiatRatesTickerId"></span>
</div>
<div class="col-8">
<div class="col-1">
<input type="text" class="form-control" id="subscribeFiatRatesCurrency" value="usd">
</div>
<div class="col">
<div class="col-5">
<input class="btn btn-secondary" id="unsubscribeNewFiatRatesTickerButton" style="display: none;" type="button" value="unsubscribe" onclick="unsubscribeNewFiatRatesTicker()">
</div>
</div>
@ -577,6 +616,7 @@
<div class="col" id="subscribeNewFiatRatesTickerResult"></div>
</div>
</div>
<br><br>
</body>
<script>
document.getElementById('serverAddress').value = window.location.protocol + "//" + window.location.host;

View File

@ -68,7 +68,7 @@ func GetTestBitcoinTypeBlock1(parser bchain.BlockChainParser) *bchain.Block {
Height: 225493,
Hash: "0000000076fbbed90fd75b0e18856aa35baa984e9c9d444cf746ad85e94e2997",
Size: 1234567,
Time: 1534858021,
Time: 1521515026,
Confirmations: 2,
},
Txs: []bchain.Tx{
@ -91,8 +91,8 @@ func GetTestBitcoinTypeBlock1(parser bchain.BlockChainParser) *bchain.Block {
ValueSat: *SatB1T1A2,
},
},
Blocktime: 22549300000,
Time: 22549300000,
Blocktime: 1521515026,
Time: 1521515026,
Confirmations: 2,
},
{
@ -120,8 +120,8 @@ func GetTestBitcoinTypeBlock1(parser bchain.BlockChainParser) *bchain.Block {
ValueSat: *SatB1T2A5,
},
},
Blocktime: 22549300001,
Time: 22549300001,
Blocktime: 1521515026,
Time: 1521515026,
Confirmations: 2,
},
},
@ -135,7 +135,7 @@ func GetTestBitcoinTypeBlock2(parser bchain.BlockChainParser) *bchain.Block {
Height: 225494,
Hash: "00000000eb0443fd7dc4a1ed5c686a8e995057805f9a161d9a5a77a95e72b7b6",
Size: 2345678,
Time: 1534859123,
Time: 1521595678,
Confirmations: 1,
},
Txs: []bchain.Tx{
@ -176,8 +176,8 @@ func GetTestBitcoinTypeBlock2(parser bchain.BlockChainParser) *bchain.Block {
ValueSat: *SatZero,
},
},
Blocktime: 22549400000,
Time: 22549400000,
Blocktime: 1521595678,
Time: 1521595678,
Confirmations: 1,
},
{
@ -210,8 +210,8 @@ func GetTestBitcoinTypeBlock2(parser bchain.BlockChainParser) *bchain.Block {
ValueSat: *SatB2T2A9,
},
},
Blocktime: 22549400001,
Time: 22549400001,
Blocktime: 1521595678,
Time: 1521595678,
Confirmations: 1,
},
// transaction from the same address in the previous block
@ -233,8 +233,8 @@ func GetTestBitcoinTypeBlock2(parser bchain.BlockChainParser) *bchain.Block {
ValueSat: *SatB2T3A5,
},
},
Blocktime: 22549400002,
Time: 22549400002,
Blocktime: 1521595678,
Time: 1521595678,
Confirmations: 1,
},
// mining transaction
@ -259,8 +259,8 @@ func GetTestBitcoinTypeBlock2(parser bchain.BlockChainParser) *bchain.Block {
ValueSat: *SatZero,
},
},
Blocktime: 22549400003,
Time: 22549400003,
Blocktime: 1521595678,
Time: 1521595678,
Confirmations: 1,
},
},