github.com/trezor/blockbook@v0.4.1-0.20240328132726-e9a08582ee2c/api/xpub.go (about) 1 package api 2 3 import ( 4 "fmt" 5 "math/big" 6 "sort" 7 "strconv" 8 "sync" 9 "time" 10 11 "github.com/golang/glog" 12 "github.com/juju/errors" 13 "github.com/trezor/blockbook/bchain" 14 "github.com/trezor/blockbook/db" 15 ) 16 17 const defaultAddressesGap = 20 18 const maxAddressesGap = 10000 19 20 const txInput = 1 21 const txOutput = 2 22 23 const xpubCacheExpirationSeconds = 3600 24 25 var cachedXpubs map[string]xpubData 26 var cachedXpubsMux sync.Mutex 27 28 const xpubLogPrefix = 30 29 30 type xpubTxid struct { 31 txid string 32 height uint32 33 inputOutput byte 34 } 35 36 type xpubTxids []xpubTxid 37 38 func (a xpubTxids) Len() int { return len(a) } 39 func (a xpubTxids) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 40 func (a xpubTxids) Less(i, j int) bool { 41 // if the heights are equal, make inputs less than outputs 42 hi := a[i].height 43 hj := a[j].height 44 if hi == hj { 45 return (a[i].inputOutput & txInput) >= (a[j].inputOutput & txInput) 46 } 47 return hi > hj 48 } 49 50 type xpubAddress struct { 51 addrDesc bchain.AddressDescriptor 52 balance *db.AddrBalance 53 txs uint32 54 maxHeight uint32 55 complete bool 56 txids xpubTxids 57 } 58 59 type xpubData struct { 60 descriptor *bchain.XpubDescriptor 61 gap int 62 accessed int64 63 basePath string 64 dataHeight uint32 65 dataHash string 66 txCountEstimate uint32 67 sentSat big.Int 68 balanceSat big.Int 69 addresses [][]xpubAddress 70 } 71 72 func (w *Worker) initXpubCache() { 73 cachedXpubsMux.Lock() 74 if cachedXpubs == nil { 75 cachedXpubs = make(map[string]xpubData) 76 go func() { 77 for { 78 time.Sleep(20 * time.Second) 79 w.evictXpubCacheItems() 80 } 81 }() 82 } 83 cachedXpubsMux.Unlock() 84 } 85 86 func (w *Worker) evictXpubCacheItems() { 87 cachedXpubsMux.Lock() 88 defer cachedXpubsMux.Unlock() 89 threshold := time.Now().Unix() - xpubCacheExpirationSeconds 90 count := 0 91 for k, v := range cachedXpubs { 92 if v.accessed < threshold { 93 delete(cachedXpubs, k) 94 count++ 95 } 96 } 97 w.metrics.XPubCacheSize.Set(float64(len(cachedXpubs))) 98 glog.Info("Evicted ", count, " items from xpub cache, cache size ", len(cachedXpubs)) 99 } 100 101 func (w *Worker) xpubGetAddressTxids(addrDesc bchain.AddressDescriptor, mempool bool, fromHeight, toHeight uint32, maxResults int) ([]xpubTxid, bool, error) { 102 var err error 103 complete := true 104 txs := make([]xpubTxid, 0, 4) 105 var callback db.GetTransactionsCallback 106 callback = func(txid string, height uint32, indexes []int32) error { 107 // take all txs in the last found block even if it exceeds maxResults 108 if len(txs) >= maxResults && txs[len(txs)-1].height != height { 109 complete = false 110 return &db.StopIteration{} 111 } 112 inputOutput := byte(0) 113 for _, index := range indexes { 114 if index < 0 { 115 inputOutput |= txInput 116 } else { 117 inputOutput |= txOutput 118 } 119 } 120 txs = append(txs, xpubTxid{txid, height, inputOutput}) 121 return nil 122 } 123 if mempool { 124 uniqueTxs := make(map[string]int) 125 o, err := w.mempool.GetAddrDescTransactions(addrDesc) 126 if err != nil { 127 return nil, false, err 128 } 129 for _, m := range o { 130 if l, found := uniqueTxs[m.Txid]; !found { 131 l = len(txs) 132 callback(m.Txid, 0, []int32{m.Vout}) 133 if len(txs) > l { 134 uniqueTxs[m.Txid] = l 135 } 136 } else { 137 if m.Vout < 0 { 138 txs[l].inputOutput |= txInput 139 } else { 140 txs[l].inputOutput |= txOutput 141 } 142 } 143 } 144 } else { 145 err = w.db.GetAddrDescTransactions(addrDesc, fromHeight, toHeight, callback) 146 if err != nil { 147 return nil, false, err 148 } 149 } 150 return txs, complete, nil 151 } 152 153 func (w *Worker) xpubCheckAndLoadTxids(ad *xpubAddress, filter *AddressFilter, maxHeight uint32, maxResults int) error { 154 // skip if not used 155 if ad.balance == nil { 156 return nil 157 } 158 // if completely loaded, check if there are not some new txs and load if necessary 159 if ad.complete { 160 if ad.balance.Txs != ad.txs { 161 newTxids, _, err := w.xpubGetAddressTxids(ad.addrDesc, false, ad.maxHeight+1, maxHeight, maxInt) 162 if err == nil { 163 ad.txids = append(newTxids, ad.txids...) 164 ad.maxHeight = maxHeight 165 ad.txs = uint32(len(ad.txids)) 166 if ad.txs != ad.balance.Txs { 167 glog.Warning("xpubCheckAndLoadTxids inconsistency ", ad.addrDesc, ", ad.txs=", ad.txs, ", ad.balance.Txs=", ad.balance.Txs) 168 } 169 } 170 return err 171 } 172 return nil 173 } 174 // load all txids to get paging correctly 175 newTxids, complete, err := w.xpubGetAddressTxids(ad.addrDesc, false, 0, maxHeight, maxInt) 176 if err != nil { 177 return err 178 } 179 ad.txids = newTxids 180 ad.complete = complete 181 ad.maxHeight = maxHeight 182 if complete { 183 ad.txs = uint32(len(ad.txids)) 184 if ad.txs != ad.balance.Txs { 185 glog.Warning("xpubCheckAndLoadTxids inconsistency ", ad.addrDesc, ", ad.txs=", ad.txs, ", ad.balance.Txs=", ad.balance.Txs) 186 } 187 } 188 return nil 189 } 190 191 func (w *Worker) xpubDerivedAddressBalance(data *xpubData, ad *xpubAddress) (bool, error) { 192 var err error 193 if ad.balance, err = w.db.GetAddrDescBalance(ad.addrDesc, db.AddressBalanceDetailUTXO); err != nil { 194 return false, err 195 } 196 if ad.balance != nil { 197 data.txCountEstimate += ad.balance.Txs 198 data.sentSat.Add(&data.sentSat, &ad.balance.SentSat) 199 data.balanceSat.Add(&data.balanceSat, &ad.balance.BalanceSat) 200 return true, nil 201 } 202 return false, nil 203 } 204 205 func (w *Worker) xpubScanAddresses(xd *bchain.XpubDescriptor, data *xpubData, addresses []xpubAddress, gap int, change uint32, minDerivedIndex int, fork bool) (int, []xpubAddress, error) { 206 // rescan known addresses 207 lastUsed := 0 208 for i := range addresses { 209 ad := &addresses[i] 210 if fork { 211 // reset the cached data 212 ad.txs = 0 213 ad.maxHeight = 0 214 ad.complete = false 215 ad.txids = nil 216 } 217 used, err := w.xpubDerivedAddressBalance(data, ad) 218 if err != nil { 219 return 0, nil, err 220 } 221 if used { 222 lastUsed = i 223 } 224 } 225 // derive new addresses as necessary 226 missing := len(addresses) - lastUsed 227 for missing < gap { 228 from := len(addresses) 229 to := from + gap - missing 230 if to < minDerivedIndex { 231 to = minDerivedIndex 232 } 233 descriptors, err := w.chainParser.DeriveAddressDescriptorsFromTo(xd, change, uint32(from), uint32(to)) 234 if err != nil { 235 return 0, nil, err 236 } 237 for i, a := range descriptors { 238 ad := xpubAddress{addrDesc: a} 239 used, err := w.xpubDerivedAddressBalance(data, &ad) 240 if err != nil { 241 return 0, nil, err 242 } 243 if used { 244 lastUsed = i + from 245 } 246 addresses = append(addresses, ad) 247 } 248 missing = len(addresses) - lastUsed 249 } 250 return lastUsed, addresses, nil 251 } 252 253 func (w *Worker) tokenFromXpubAddress(data *xpubData, ad *xpubAddress, changeIndex int, index int, option AccountDetails) Token { 254 a, _, _ := w.chainParser.GetAddressesFromAddrDesc(ad.addrDesc) 255 var address string 256 if len(a) > 0 { 257 address = a[0] 258 } 259 var balance, totalReceived, totalSent *big.Int 260 var transfers int 261 if ad.balance != nil { 262 transfers = int(ad.balance.Txs) 263 if option >= AccountDetailsTokenBalances { 264 balance = &ad.balance.BalanceSat 265 totalSent = &ad.balance.SentSat 266 totalReceived = ad.balance.ReceivedSat() 267 } 268 } 269 return Token{ 270 Type: bchain.XPUBAddressTokenType, 271 Name: address, 272 Decimals: w.chainParser.AmountDecimals(), 273 BalanceSat: (*Amount)(balance), 274 TotalReceivedSat: (*Amount)(totalReceived), 275 TotalSentSat: (*Amount)(totalSent), 276 Transfers: transfers, 277 Path: fmt.Sprintf("%s/%d/%d", data.basePath, changeIndex, index), 278 } 279 } 280 281 // returns true if addresses are "own", i.e. the address belongs to the xpub 282 func isOwnAddresses(xpubAddresses map[string]struct{}, addresses []string) bool { 283 if len(addresses) == 1 { 284 _, found := xpubAddresses[addresses[0]] 285 return found 286 } 287 return false 288 } 289 290 func setIsOwnAddresses(txs []*Tx, xpubAddresses map[string]struct{}) { 291 for i := range txs { 292 tx := txs[i] 293 for j := range tx.Vin { 294 vin := &tx.Vin[j] 295 if isOwnAddresses(xpubAddresses, vin.Addresses) { 296 vin.IsOwn = true 297 } 298 } 299 for j := range tx.Vout { 300 vout := &tx.Vout[j] 301 if isOwnAddresses(xpubAddresses, vout.Addresses) { 302 vout.IsOwn = true 303 } 304 } 305 } 306 } 307 308 func (w *Worker) getXpubData(xd *bchain.XpubDescriptor, page int, txsOnPage int, option AccountDetails, filter *AddressFilter, gap int) (*xpubData, uint32, bool, error) { 309 if w.chainType != bchain.ChainBitcoinType { 310 return nil, 0, false, ErrUnsupportedXpub 311 } 312 var ( 313 err error 314 bestheight uint32 315 besthash string 316 ) 317 if gap <= 0 { 318 gap = defaultAddressesGap 319 } else if gap > maxAddressesGap { 320 // limit the maximum gap to protect against unreasonably big values that could cause high load of the server 321 gap = maxAddressesGap 322 } 323 // gap is increased one as there must be gap of empty addresses before the derivation is stopped 324 gap++ 325 var processedHash string 326 cachedXpubsMux.Lock() 327 data, inCache := cachedXpubs[xd.XpubDescriptor] 328 cachedXpubsMux.Unlock() 329 // to load all data for xpub may take some time, do it in a loop to process a possible new block 330 for { 331 bestheight, besthash, err = w.db.GetBestBlock() 332 if err != nil { 333 return nil, 0, inCache, errors.Annotatef(err, "GetBestBlock") 334 } 335 if besthash == processedHash { 336 break 337 } 338 fork := false 339 if !inCache || data.gap != gap { 340 data = xpubData{ 341 gap: gap, 342 addresses: make([][]xpubAddress, len(xd.ChangeIndexes)), 343 } 344 data.basePath, err = w.chainParser.DerivationBasePath(xd) 345 if err != nil { 346 return nil, 0, inCache, err 347 } 348 } else { 349 hash, err := w.db.GetBlockHash(data.dataHeight) 350 if err != nil { 351 return nil, 0, inCache, err 352 } 353 if hash != data.dataHash { 354 // in case of for reset all cached data 355 fork = true 356 } 357 } 358 processedHash = besthash 359 if data.dataHeight < bestheight || fork { 360 data.dataHeight = bestheight 361 data.dataHash = besthash 362 data.balanceSat = *new(big.Int) 363 data.sentSat = *new(big.Int) 364 data.txCountEstimate = 0 365 var minDerivedIndex int 366 for i, change := range xd.ChangeIndexes { 367 minDerivedIndex, data.addresses[i], err = w.xpubScanAddresses(xd, &data, data.addresses[i], gap, change, minDerivedIndex, fork) 368 if err != nil { 369 return nil, 0, inCache, err 370 } 371 } 372 } 373 if option >= AccountDetailsTxidHistory { 374 for _, da := range data.addresses { 375 for i := range da { 376 if err = w.xpubCheckAndLoadTxids(&da[i], filter, bestheight, (page+1)*txsOnPage); err != nil { 377 return nil, 0, inCache, err 378 } 379 } 380 } 381 } 382 } 383 data.accessed = time.Now().Unix() 384 cachedXpubsMux.Lock() 385 cachedXpubs[xd.XpubDescriptor] = data 386 cachedXpubsMux.Unlock() 387 return &data, bestheight, inCache, nil 388 } 389 390 // GetXpubAddress computes address value and gets transactions for given address 391 func (w *Worker) GetXpubAddress(xpub string, page int, txsOnPage int, option AccountDetails, filter *AddressFilter, gap int, secondaryCoin string) (*Address, error) { 392 start := time.Now() 393 page-- 394 if page < 0 { 395 page = 0 396 } 397 type mempoolMap struct { 398 tx *Tx 399 inputOutput byte 400 } 401 var ( 402 txc xpubTxids 403 txmMap map[string]*Tx 404 txCount int 405 txs []*Tx 406 txids []string 407 pg Paging 408 filtered bool 409 uBalSat big.Int 410 unconfirmedTxs int 411 ) 412 xd, err := w.chainParser.ParseXpub(xpub) 413 if err != nil { 414 return nil, err 415 } 416 data, bestheight, inCache, err := w.getXpubData(xd, page, txsOnPage, option, filter, gap) 417 if err != nil { 418 return nil, err 419 } 420 // setup filtering of txids 421 var txidFilter func(txid *xpubTxid, ad *xpubAddress) bool 422 if !(filter.FromHeight == 0 && filter.ToHeight == 0 && filter.Vout == AddressFilterVoutOff) { 423 toHeight := maxUint32 424 if filter.ToHeight != 0 { 425 toHeight = filter.ToHeight 426 } 427 txidFilter = func(txid *xpubTxid, ad *xpubAddress) bool { 428 if txid.height < filter.FromHeight || txid.height > toHeight { 429 return false 430 } 431 if filter.Vout != AddressFilterVoutOff { 432 if filter.Vout == AddressFilterVoutInputs && txid.inputOutput&txInput == 0 || 433 filter.Vout == AddressFilterVoutOutputs && txid.inputOutput&txOutput == 0 { 434 return false 435 } 436 } 437 return true 438 } 439 filtered = true 440 } 441 addresses := w.newAddressesMapForAliases() 442 // process mempool, only if ToHeight is not specified 443 if filter.ToHeight == 0 && !filter.OnlyConfirmed { 444 txmMap = make(map[string]*Tx) 445 mempoolEntries := make(bchain.MempoolTxidEntries, 0) 446 for _, da := range data.addresses { 447 for i := range da { 448 ad := &da[i] 449 newTxids, _, err := w.xpubGetAddressTxids(ad.addrDesc, true, 0, 0, maxInt) 450 if err != nil { 451 return nil, err 452 } 453 for _, txid := range newTxids { 454 // the same tx can have multiple addresses from the same xpub, get it from backend it only once 455 tx, foundTx := txmMap[txid.txid] 456 if !foundTx { 457 tx, err = w.getTransaction(txid.txid, false, true, addresses) 458 // mempool transaction may fail 459 if err != nil || tx == nil { 460 glog.Warning("GetTransaction in mempool: ", err) 461 continue 462 } 463 txmMap[txid.txid] = tx 464 } 465 // skip already confirmed txs, mempool may be out of sync 466 if tx.Confirmations == 0 { 467 if !foundTx { 468 unconfirmedTxs++ 469 } 470 uBalSat.Add(&uBalSat, tx.getAddrVoutValue(ad.addrDesc)) 471 uBalSat.Sub(&uBalSat, tx.getAddrVinValue(ad.addrDesc)) 472 // mempool txs are returned only on the first page, uniquely and filtered 473 if page == 0 && !foundTx && (txidFilter == nil || txidFilter(&txid, ad)) { 474 mempoolEntries = append(mempoolEntries, bchain.MempoolTxidEntry{Txid: txid.txid, Time: uint32(tx.Blocktime)}) 475 } 476 } 477 } 478 } 479 } 480 // sort the entries by time descending 481 sort.Sort(mempoolEntries) 482 for _, entry := range mempoolEntries { 483 if option == AccountDetailsTxidHistory { 484 txids = append(txids, entry.Txid) 485 } else if option >= AccountDetailsTxHistoryLight { 486 txs = append(txs, txmMap[entry.Txid]) 487 } 488 } 489 } 490 if option >= AccountDetailsTxidHistory { 491 txcMap := make(map[string]bool) 492 txc = make(xpubTxids, 0, 32) 493 for _, da := range data.addresses { 494 for i := range da { 495 ad := &da[i] 496 for _, txid := range ad.txids { 497 added, foundTx := txcMap[txid.txid] 498 // count txs regardless of filter but only once 499 if !foundTx { 500 txCount++ 501 } 502 // add tx only once 503 if !added { 504 add := txidFilter == nil || txidFilter(&txid, ad) 505 txcMap[txid.txid] = add 506 if add { 507 txc = append(txc, txid) 508 } 509 } 510 } 511 } 512 } 513 sort.Stable(txc) 514 txCount = len(txcMap) 515 totalResults := txCount 516 if filtered { 517 totalResults = -1 518 } 519 var from, to int 520 pg, from, to, page = computePaging(len(txc), page, txsOnPage) 521 if len(txc) >= txsOnPage { 522 if totalResults < 0 { 523 pg.TotalPages = -1 524 } else { 525 pg, _, _, _ = computePaging(totalResults, page, txsOnPage) 526 } 527 } 528 // get confirmed transactions 529 for i := from; i < to; i++ { 530 xpubTxid := &txc[i] 531 if option == AccountDetailsTxidHistory { 532 txids = append(txids, xpubTxid.txid) 533 } else { 534 tx, err := w.txFromTxid(xpubTxid.txid, bestheight, option, nil, addresses) 535 if err != nil { 536 return nil, err 537 } 538 txs = append(txs, tx) 539 } 540 } 541 } else { 542 txCount = int(data.txCountEstimate) 543 } 544 addrTxCount := int(data.txCountEstimate) 545 usedTokens := 0 546 var tokens []Token 547 var xpubAddresses map[string]struct{} 548 if option > AccountDetailsBasic { 549 tokens = make([]Token, 0, 4) 550 xpubAddresses = make(map[string]struct{}) 551 } 552 for ci, da := range data.addresses { 553 for i := range da { 554 ad := &da[i] 555 if ad.balance != nil { 556 usedTokens++ 557 } 558 if option > AccountDetailsBasic { 559 token := w.tokenFromXpubAddress(data, ad, int(xd.ChangeIndexes[ci]), i, option) 560 if filter.TokensToReturn == TokensToReturnDerived || 561 filter.TokensToReturn == TokensToReturnUsed && ad.balance != nil || 562 filter.TokensToReturn == TokensToReturnNonzeroBalance && ad.balance != nil && !IsZeroBigInt(&ad.balance.BalanceSat) { 563 tokens = append(tokens, token) 564 } 565 xpubAddresses[token.Name] = struct{}{} 566 } 567 } 568 } 569 setIsOwnAddresses(txs, xpubAddresses) 570 var totalReceived big.Int 571 totalReceived.Add(&data.balanceSat, &data.sentSat) 572 573 var secondaryValue float64 574 if secondaryCoin != "" { 575 ticker := w.fiatRates.GetCurrentTicker("", "") 576 balance, err := strconv.ParseFloat((*Amount)(&data.balanceSat).DecimalString(w.chainParser.AmountDecimals()), 64) 577 if ticker != nil && err == nil { 578 r, found := ticker.Rates[secondaryCoin] 579 if found { 580 secondaryRate := float64(r) 581 secondaryValue = secondaryRate * balance 582 } 583 } 584 } 585 586 addr := Address{ 587 Paging: pg, 588 AddrStr: xpub, 589 BalanceSat: (*Amount)(&data.balanceSat), 590 TotalReceivedSat: (*Amount)(&totalReceived), 591 TotalSentSat: (*Amount)(&data.sentSat), 592 Txs: txCount, 593 AddrTxCount: addrTxCount, 594 UnconfirmedBalanceSat: (*Amount)(&uBalSat), 595 UnconfirmedTxs: unconfirmedTxs, 596 Transactions: txs, 597 Txids: txids, 598 UsedTokens: usedTokens, 599 Tokens: tokens, 600 SecondaryValue: secondaryValue, 601 XPubAddresses: xpubAddresses, 602 AddressAliases: w.getAddressAliases(addresses), 603 } 604 glog.Info("GetXpubAddress ", xpub[:xpubLogPrefix], ", cache ", inCache, ", ", txCount, " txs, ", time.Since(start)) 605 return &addr, nil 606 } 607 608 // GetXpubUtxo returns unspent outputs for given xpub 609 func (w *Worker) GetXpubUtxo(xpub string, onlyConfirmed bool, gap int) (Utxos, error) { 610 start := time.Now() 611 xd, err := w.chainParser.ParseXpub(xpub) 612 if err != nil { 613 return nil, err 614 } 615 data, _, inCache, err := w.getXpubData(xd, 0, 1, AccountDetailsBasic, &AddressFilter{ 616 Vout: AddressFilterVoutOff, 617 OnlyConfirmed: onlyConfirmed, 618 }, gap) 619 if err != nil { 620 return nil, err 621 } 622 r := make(Utxos, 0, 8) 623 for ci, da := range data.addresses { 624 for i := range da { 625 ad := &da[i] 626 onlyMempool := false 627 if ad.balance == nil { 628 if onlyConfirmed { 629 continue 630 } 631 onlyMempool = true 632 } 633 utxos, err := w.getAddrDescUtxo(ad.addrDesc, ad.balance, onlyConfirmed, onlyMempool) 634 if err != nil { 635 return nil, err 636 } 637 if len(utxos) > 0 { 638 t := w.tokenFromXpubAddress(data, ad, ci, i, AccountDetailsTokens) 639 for j := range utxos { 640 a := &utxos[j] 641 a.Address = t.Name 642 a.Path = t.Path 643 } 644 r = append(r, utxos...) 645 } 646 } 647 } 648 sort.Stable(r) 649 glog.Info("GetXpubUtxo ", xpub[:xpubLogPrefix], ", cache ", inCache, ", ", len(r), " utxos, ", time.Since(start)) 650 return r, nil 651 } 652 653 // GetXpubBalanceHistory returns history of balance for given xpub 654 func (w *Worker) GetXpubBalanceHistory(xpub string, fromTimestamp, toTimestamp int64, currencies []string, gap int, groupBy uint32) (BalanceHistories, error) { 655 bhs := make(BalanceHistories, 0) 656 start := time.Now() 657 fromUnix, fromHeight, toUnix, toHeight := w.balanceHistoryHeightsFromTo(fromTimestamp, toTimestamp) 658 if fromHeight >= toHeight { 659 return bhs, nil 660 } 661 xd, err := w.chainParser.ParseXpub(xpub) 662 if err != nil { 663 return nil, err 664 } 665 data, _, inCache, err := w.getXpubData(xd, 0, 1, AccountDetailsTxidHistory, &AddressFilter{ 666 Vout: AddressFilterVoutOff, 667 OnlyConfirmed: true, 668 FromHeight: fromHeight, 669 ToHeight: toHeight, 670 }, gap) 671 if err != nil { 672 return nil, err 673 } 674 selfAddrDesc := make(map[string]struct{}) 675 for _, da := range data.addresses { 676 for i := range da { 677 selfAddrDesc[string(da[i].addrDesc)] = struct{}{} 678 } 679 } 680 for _, da := range data.addresses { 681 for i := range da { 682 ad := &da[i] 683 txids := ad.txids 684 for txi := len(txids) - 1; txi >= 0; txi-- { 685 bh, err := w.balanceHistoryForTxid(ad.addrDesc, txids[txi].txid, fromUnix, toUnix, selfAddrDesc) 686 if err != nil { 687 return nil, err 688 } 689 if bh != nil { 690 bhs = append(bhs, *bh) 691 } 692 } 693 } 694 } 695 bha := bhs.SortAndAggregate(groupBy) 696 err = w.setFiatRateToBalanceHistories(bha, currencies) 697 if err != nil { 698 return nil, err 699 } 700 glog.Info("GetUtxoBalanceHistory ", xpub[:xpubLogPrefix], ", cache ", inCache, ", blocks ", fromHeight, "-", toHeight, ", count ", len(bha), ", ", time.Since(start)) 701 return bha, nil 702 }