From 2d83ee9e27ab820d8a9cd05a4b0d921abe332f99 Mon Sep 17 00:00:00 2001
From: Benoit Lavenier <benoit.lavenier@e-is.pro>
Date: Fri, 11 Aug 2023 19:36:54 +0200
Subject: [PATCH] enh(tx): Compute and use an tx.id, in ng-repeat enh(bma):
 Avoid too many request error, when loading tx history very quickly

---
 www/js/controllers/wallet-controllers.js |  38 ++---
 www/js/services/bma-services.js          | 186 +++++++++++++++++------
 www/js/services/network-services.js      |   2 +-
 www/js/services/settings-services.js     |   9 +-
 www/js/services/tx-services.js           |  88 +++++++----
 www/templates/wallet/view_wallet_tx.html |   8 +-
 6 files changed, 226 insertions(+), 105 deletions(-)

diff --git a/www/js/controllers/wallet-controllers.js b/www/js/controllers/wallet-controllers.js
index c58079fb5..20c2f3c8b 100644
--- a/www/js/controllers/wallet-controllers.js
+++ b/www/js/controllers/wallet-controllers.js
@@ -727,12 +727,14 @@ function WalletTxController($scope, $ionicPopover, $state, $timeout, $location,
     if (!wallet) return $q.reject('Missing wallet');
 
     var hasMinData = wallet.isDataLoaded({minData: true});
+    var fromTime = csHttp.date.now() - csSettings.data.walletHistoryTimeSecond;
     var options = {
       requirements: !hasMinData, // load requirements (=minData) once
       minData: !hasMinData,
       sources: true,
       tx: {
-        enable: true
+        enable: true,
+        fromTime: fromTime
       }
     };
 
@@ -773,10 +775,13 @@ function WalletTxController($scope, $ionicPopover, $state, $timeout, $location,
 
   // Update view
   $scope.updateView = function() {
-    if (!$scope.formData || $scope.loading) return;
+    if (!$scope.formData || !$scope.formData.tx || $scope.loading) return;
 
-    var fetchMoreMinTime = csHttp.date.now() - csSettings.data.walletHistoryTimeSecond * 5;
-    $scope.formData.tx.canFetchMore = $scope.formData.tx.fromTime > 0 && $scope.formData.tx.fromTime > fetchMoreMinTime;
+    $scope.minScrollMoreTime = moment().utc()
+      .subtract(csSettings.data.walletHistoryScrollMaxTimeSecond, 'second')
+      .startOf('day')
+      .unix();
+    $scope.canScrollMore = $scope.formData.tx.fromTime > $scope.minScrollMoreTime;
 
     $scope.$broadcast('$$rebind::balance'); // force rebind balance
     $scope.$broadcast('$$rebind::rebind'); // force rebind
@@ -793,7 +798,7 @@ function WalletTxController($scope, $ionicPopover, $state, $timeout, $location,
   // Updating wallet data
   $scope.doUpdate = function(silent) {
     console.debug('[wallet] TX history reloading...');
-    var fromTime = $scope.formData &&  $scope.formData.tx && $scope.formData.tx.fromTime || undefined;
+    var fromTime = $scope.formData && $scope.formData.tx && $scope.formData.tx.fromTime || undefined;
     var options = {
       sources: true,
       tx:  {
@@ -802,6 +807,7 @@ function WalletTxController($scope, $ionicPopover, $state, $timeout, $location,
       },
       api: false
     };
+
     return (silent ?
         // If silent: just refresh
         wallet.refreshData(options) :
@@ -910,9 +916,9 @@ function WalletTxController($scope, $ionicPopover, $state, $timeout, $location,
 
 
   $scope.showMoreTx = function(fromTime) {
-    if ($scope.formData.tx.loadingMore) return; // Skip
+    if ($scope.loadingMore) return; // Skip
 
-    $scope.formData.tx.loadingMore = true;
+    $scope.loadingMore = true;
 
     fromTime = fromTime ||
       ($scope.formData.tx.fromTime - csSettings.data.walletHistoryTimeSecond) ||
@@ -920,26 +926,24 @@ function WalletTxController($scope, $ionicPopover, $state, $timeout, $location,
 
     console.info('[wallet-tx] Fetching more TX, since: ' + fromTime);
 
-    return wallet.refreshData({tx: {enable: true, fromTime: fromTime}})
+    return wallet.refreshData({sources: true, tx: {enable: true, fromTime: fromTime}})
       .then(function() {
+        $scope.loadingMore = false;
         $scope.updateView();
-        $scope.formData.tx.loadingMore = false;
         $scope.$broadcast('scroll.infiniteScrollComplete');
       })
       .catch(function(err) {
         // If http rest limitation: wait then retry
-        if (err.ucode == BMA.errorCodes.HTTP_LIMITATION) {
-          $timeout(function() {
+        if (err.ucode === BMA.errorCodes.HTTP_LIMITATION) {
+          return $timeout(function() {
             return $scope.showMoreTx(fromTime); // Loop
           }, 2000);
         }
-        else {
-          $scope.formData.tx.loadingMore = false;
-          $scope.formData.tx.canFetchMore = false;
-          $scope.$broadcast('scroll.infiniteScrollComplete');
 
-          UIUtils.onError('ERROR.REFRESH_WALLET_DATA')(err);
-        }
+        $scope.loadingMore = false;
+        $scope.canScrollMore = false;
+        $scope.$broadcast('scroll.infiniteScrollComplete');
+        UIUtils.onError('ERROR.REFRESH_WALLET_DATA')(err);
       });
   };
 
diff --git a/www/js/services/bma-services.js b/www/js/services/bma-services.js
index 1f5cbba5f..6d768752a 100644
--- a/www/js/services/bma-services.js
+++ b/www/js/services/bma-services.js
@@ -91,6 +91,7 @@ angular.module('cesium.bma.services', ['ngApi', 'cesium.http.services', 'cesium.
 
     that.raw = {
       getByPath: {},
+      getCountByPath: {},
       postByPath: {},
       wsByPath: {}
     };
@@ -154,33 +155,37 @@ angular.module('cesium.bma.services', ['ngApi', 'cesium.http.services', 'cesium.
       that.raw.wsByPath = {};
     }
 
-   function cleanCache() {
-     console.debug("[BMA] Cleaning cache {prefix: '{0}'}...".format(cachePrefix));
-     csCache.clear(cachePrefix);
+    function cleanCache() {
+      console.debug("[BMA] Cleaning cache {prefix: '{0}'}...".format(cachePrefix));
+      csCache.clear(cachePrefix);
 
-     // Clean raw requests by path cache
-     that.raw.getByPath = {};
-     that.raw.postByPath = {};
-     that.raw.wsByPath = {};
-   }
+      // Clean raw requests by path cache
+      that.raw.getByPath = {};
+      that.raw.getCountByPath = {};
+      that.raw.postByPath = {};
+      that.raw.wsByPath = {};
+    }
 
-    function get(path, cacheTime, forcedTimeout) {
+    function getCacheable(path, cacheTime, forcedTimeout) {
 
       cacheTime = that.useCache && cacheTime || 0 /* no cache*/ ;
       forcedTimeout = forcedTimeout || timeout;
       var cacheKey = path + (cacheTime ? ('#'+cacheTime) : '');
 
-      var getRequestFn = function(params) {
+      // Store requestFn into a variable a function, to be able to call it to loop
+      var wrappedRequest = function(params) {
 
         if (!that.started) {
           if (!that._startPromise) {
             console.warn('[BMA] Trying to get [{0}] before start(). Waiting...'.format(path));
           }
-          return that.ready().then(function() {
-            return getRequestFn(params);
-          });
+          return that.ready()
+            .then(function() {
+              return wrappedRequest(params);
+            });
         }
 
+        // Create the request function, if not exists
         var request = that.raw.getByPath[cacheKey];
         if (!request) {
           if (cacheTime) {
@@ -189,32 +194,75 @@ angular.module('cesium.bma.services', ['ngApi', 'cesium.http.services', 'cesium.
           else {
             request = csHttp.get(that.host, that.port, that.path + path, that.useSsl, forcedTimeout);
           }
+
           that.raw.getByPath[cacheKey] = request;
         }
-        var execCount = 1;
-        return request(params)
-          .catch(function(err){
-            // If node return too many requests error
-            if (err && err.ucode === exports.errorCodes.HTTP_LIMITATION) {
-              // If max number of retry not reach
-              if (execCount <= exports.constants.LIMIT_REQUEST_COUNT) {
-                if (execCount === 1) {
-                  console.warn("[BMA] Too many HTTP requests: Will wait then retry...");
-                  // Update the loading message (if exists)
-                  UIUtils.loading.update({template: "COMMON.LOADING_WAIT"});
-                }
-                // Wait 1s then retry
-                return $timeout(function() {
-                  execCount++;
-                  return request(params);
-                }, exports.constants.LIMIT_REQUEST_DELAY);
-              }
+
+        return request(params);
+      };
+
+      return wrappedRequest;
+    }
+
+    function incrementGetPathCount(path, limitRequestCount) {
+      limitRequestCount = limitRequestCount || constants.LIMIT_REQUEST_COUNT;
+      // Wait if too many requests on this path
+      if (that.raw.getCountByPath[path] >= limitRequestCount) {
+
+        // DEBUG
+        //console.debug("[BMA] Delaying request '{0}' to avoid a quota error...".format(path));
+
+        return $timeout(function() {
+          return incrementGetPathCount(path, limitRequestCount);
+        }, constants.LIMIT_REQUEST_DELAY);
+      }
+
+      that.raw.getCountByPath[path]++;
+      return $q.when();
+    }
+
+    function decrementGetPathCount(path, timeout) {
+      if (timeout > 0) {
+        $timeout(function() {
+          decrementGetPathCount(path);
+        }, timeout);
+      }
+      else {
+        that.raw.getCountByPath[path]--;
+      }
+    }
+
+    function get(path, cacheTime, forcedTimeout, limitRequestCount) {
+      limitRequestCount = limitRequestCount || constants.LIMIT_REQUEST_COUNT;
+
+      that.raw.getCountByPath[path] = that.raw.getCountByPath[path] || 0;
+      var request = getCacheable(path, cacheTime, forcedTimeout);
+
+      var wrappedRequest = function(params) {
+
+        var start = Date.now();
+        return incrementGetPathCount(path, limitRequestCount)
+          .then(function() {
+            return request(params);
+          })
+          .then(function(res) {
+            decrementGetPathCount(path, constants.LIMIT_REQUEST_DELAY - (Date.now() - start));
+            return res;
+          })
+          .catch(function (err) {
+            decrementGetPathCount(path, constants.LIMIT_REQUEST_DELAY - (Date.now() - start));
+            // When too many request, retry in 3s
+            if (err && err.ucode === errorCodes.HTTP_LIMITATION) {
+              // retry
+              return $timeout(function () {
+                return wrappedRequest(params);
+              }, constants.LIMIT_REQUEST_DELAY);
             }
             throw err;
           });
       };
 
-      return getRequestFn;
+      return wrappedRequest;
     }
 
     function post(path) {
@@ -490,7 +538,7 @@ angular.module('cesium.bma.services', ['ngApi', 'cesium.http.services', 'cesium.
       network: {
         peering: {
           self: get('/network/peering'),
-          peers: get('/network/peering/peers')
+          peers: get('/network/peering/peers', null, null, 10)
         },
         peers: get('/network/peers'),
         ws2p: {
@@ -880,10 +928,12 @@ angular.module('cesium.bma.services', ['ngApi', 'cesium.http.services', 'cesium.
 
     /**
      * Return all expected blocks
-     * @param blockNumbers a rray of block number
+     * @param blockNumbers an array of block number
     */
     exports.blockchain.blocks = function(blockNumbers){
-      return exports.raw.getHttpRecursive(exports.blockchain.block, 'block', blockNumbers);
+      return $q.all(blockNumbers.map(function(block) {
+        return exports.blockchain.block({block: block})
+      }));
     };
 
     /**
@@ -891,7 +941,9 @@ angular.module('cesium.bma.services', ['ngApi', 'cesium.http.services', 'cesium.
      * @param blockNumbers a rray of block number
      */
     exports.network.peering.peersByLeaves = function(leaves){
-      return exports.raw.getHttpRecursive(exports.network.peering.peers, 'leaf', leaves, 0, 10);
+      return $q.all(leaves.map(function(leaf) {
+        return exports.network.peering.peers({leaf: leaf})
+      }));
     };
 
     exports.raw.getHttpRecursive = function(httpGetRequest, paramName, paramValues, offset, size) {
@@ -900,7 +952,8 @@ angular.module('cesium.bma.services', ['ngApi', 'cesium.http.services', 'cesium.
       return $q(function(resolve, reject) {
         var result = [];
         var jobs = [];
-        _.each(paramValues.slice(offset, offset+size), function(paramValue) {
+        var chunk = paramValues.slice(offset, offset+size)
+        _.each(chunk, function(paramValue) {
           var requestParams = {};
           requestParams[paramName] = paramValue;
           jobs.push(
@@ -945,17 +998,56 @@ angular.module('cesium.bma.services', ['ngApi', 'cesium.http.services', 'cesium.
       });
     };
 
-    exports.raw.getHttpWithRetryIfLimitation = function(exec) {
-      return exec()
-        .catch(function(err){
-          // When too many request, retry in 3s
-          if (err && err.ucode == exports.errorCodes.HTTP_LIMITATION) {
-            return $timeout(function() {
-              // retry
-              return exports.raw.getHttpWithRetryIfLimitation(exec);
-            }, exports.constants.LIMIT_REQUEST_DELAY);
-          }
+    function retryableSlices(getRequestFns, slices, offset, size) {
+      offset = angular.isDefined(offset) ? offset : 0;
+      size = size || exports.constants.LIMIT_REQUEST_COUNT;
+      return $q(function(resolve, reject) {
+        var result = [];
+        var jobs = [];
+        var chunk = slices.slice(offset, offset+size)
+        _.each(chunk, function(params) {
+          jobs.push(
+            getRequestFns(params)
+              .then(function(res){
+                if (!res) return;
+                result.push(res);
+              })
+          );
         });
+
+        $q.all(jobs)
+          .then(function() {
+            if (offset < slices.length - 1) {
+              // Loop, with a new offset
+              $timeout(function() {
+                exports.raw.getHttpRecursive(getRequestFns, slices, offset+size, size)
+                  .then(function(res) {
+                    if (!res || !res.length) {
+                      resolve(result);
+                      return;
+                    }
+
+                    resolve(result.concat(res));
+                  })
+                  .catch(function(err) {
+                    reject(err);
+                  });
+              }, exports.constants.LIMIT_REQUEST_DELAY);
+            }
+            // End
+            else {
+              resolve(result);
+            }
+          })
+          .catch(function(err){
+            if (err && err.ucode === exports.errorCodes.HTTP_LIMITATION) {
+              resolve(result);
+            }
+            else {
+              reject(err);
+            }
+          });
+      });
     };
 
     exports.blockchain.lastUd = function() {
diff --git a/www/js/services/network-services.js b/www/js/services/network-services.js
index 8b50baad9..0569e4432 100644
--- a/www/js/services/network-services.js
+++ b/www/js/services/network-services.js
@@ -144,7 +144,7 @@ angular.module('cesium.network.services', ['ngApi', 'cesium.currency.services',
         })
         .catch(function(err) {
           // When too many request, retry in 3s
-          if (err && err.ucode == BMA.errorCodes.HTTP_LIMITATION) {
+          if (err && err.ucode === BMA.errorCodes.HTTP_LIMITATION) {
             return $timeout(function() {
               if (remainingTime() > 0) return loadW2spHeads();
             }, 3000);
diff --git a/www/js/services/settings-services.js b/www/js/services/settings-services.js
index 7a37c0de4..7d9b60678 100644
--- a/www/js/services/settings-services.js
+++ b/www/js/services/settings-services.js
@@ -89,12 +89,13 @@ angular.module('cesium.settings.services', ['ngApi', 'cesium.config'])
   defaultSettings = angular.merge({
     timeout: -1, // -1 = auto
     useRelative: false,
-    useLocalStorage: !!$window.localStorage, // override to false if no device
+    useLocalStorage: !!$window.localStorage, // Overwritten to false if not a device
     useLocalStorageEncryption: false,
     persistCache: false, // disable by default (waiting resolution of issue #885)
-    walletHistoryTimeSecond: 30 * 24 * 60 * 60 /*30 days*/,
-    walletHistorySliceSecond: 5 * 24 * 60 * 60 /*download using 5 days slice*/,
-    walletHistoryAutoRefresh: true, // override to false if device
+    walletHistoryTimeSecond: 30 * 24 * 60 * 60, // 30 days
+    walletHistorySliceSecond: 5 * 24 * 60 * 60, // download using 5 days slice - need for cache
+    walletHistoryScrollMaxTimeSecond: 2 * 30 * 24 * 60 * 60, // Limit TX load infinite scroll to 2 month
+    walletHistoryAutoRefresh: true, // Reload TX history on new block ? Overwritten to false if device
     rememberMe: true,
     keepAuthIdle: 10 * 60,
     showUDHistory: true,
diff --git a/www/js/services/tx-services.js b/www/js/services/tx-services.js
index 88e1ee4a5..d98b85fd3 100644
--- a/www/js/services/tx-services.js
+++ b/www/js/services/tx-services.js
@@ -95,11 +95,12 @@ angular.module('cesium.tx.services', ['ngApi', 'cesium.bma.services',
         var txPubkeys = amount > 0 ? otherIssuers : otherRecipients;
         var time = tx.time || tx.blockstampTime;
 
-        // Avoid duplicated tx, or tx to him self
-        var txKey = (amount !== 0) && amount + ':' + tx.hash + ':' + time;
+        // Avoid duplicated tx, or tx to him self (if amount = 0)
+        var txKey = (amount !== 0) ? [amount, tx.hash, time].join(':') : undefined;
         if (txKey && !processedTxMap[txKey]) {
           processedTxMap[txKey] = true; // Mark as processed
           var newTx = {
+            id: txKey,
             time: time,
             amount: amount,
             pubkey: txPubkeys.length === 1 ? txPubkeys[0] : undefined,
@@ -139,6 +140,7 @@ angular.module('cesium.tx.services', ['ngApi', 'cesium.bma.services',
       };
 
       var processedTxMap = {};
+      var retryPendingCount = 0;
 
       var jobs = [
         // get current block
@@ -146,6 +148,15 @@ angular.module('cesium.tx.services', ['ngApi', 'cesium.bma.services',
 
         // get pending tx
         BMA.tx.history.pending({pubkey: pubkey})
+          .catch(function(err) {
+            if (err && err.ucode === BMA.errorCodes.HTTP_LIMITATION && retryPendingCount < 3) {
+              retryPendingCount++;
+              return $timeout(function() {
+                return BMA.tx.history.pending({pubkey: pubkey})
+              }, 2000 * retryPendingCount);
+            }
+            throw err;
+          })
           .then(function (res) {
             reduceTxAndPush(pubkey, res.history.sending, tx.pendings, processedTxMap, true /*allow pendings*/);
             reduceTxAndPush(pubkey, res.history.pending, tx.pendings, processedTxMap, true /*allow pendings*/);
@@ -176,9 +187,9 @@ angular.module('cesium.tx.services', ['ngApi', 'cesium.bma.services',
 
         // get TX from a given time
         if (fromTime > 0) {
-          jobs.push(slices.map(function(slice) {
-              return BMA.tx.history.times(slice.params, slice.cache).then(reduceTxFn);
-          }));
+          jobs.push($q.all(slices.map(function(slice) {
+            return BMA.tx.history.times(slice.params, slice.cache).then(reduceTxFn)
+          })));
         }
 
         // get all TX
@@ -194,6 +205,7 @@ angular.module('cesium.tx.services', ['ngApi', 'cesium.bma.services',
               if (ud.time < fromTime) return res; // skip to old UD
               var amount = powBase(ud.amount, ud.base);
               tx.history.push({
+                id: [amount, 'ud', ud.time].join(':'),
                 time: ud.time,
                 amount: amount,
                 isUD: true,
@@ -203,10 +215,10 @@ angular.module('cesium.tx.services', ['ngApi', 'cesium.bma.services',
           };
 
           // get UD from a given time
-          if ( fromTime > 0) {
-            jobs.push(slices.map(function(slice) {
+          if (fromTime > 0) {
+            jobs.push($q.all(slices.map(function(slice) {
               return BMA.ud.history.times(slice.params, slice.cache).then(reduceUdFn);
-            }));
+            })));
           }
           // get all UD
           else {
@@ -310,38 +322,48 @@ angular.module('cesium.tx.services', ['ngApi', 'cesium.bma.services',
 
       function _processPendingTx(tx) {
         var consumedSources = [];
-        var valid = true;
-        if (tx.amount > 0) { // do not check sources from received TX
-          valid = false;
-          // TODO get sources from the issuer ?
+        // do not check sources from received TX
+        // => move this tx in errors (even if not really)
+        if (tx.amount > 0) {
+          txErrors.push(tx);
         }
         else {
+          var validInputs = true;
           _.find(tx.inputs, function(input) {
             var inputKey = input.split(':').slice(2).join(':');
             var srcIndex = data.sourcesIndexByKey[inputKey];
-            if (angular.isDefined(srcIndex)) {
-              consumedSources.push(data.sources[srcIndex]);
-            }
-            else {
-              valid = false;
+
+            // The input source not exists: mark as invalid
+            if (!angular.isDefined(srcIndex)) {
+              validInputs = false;
               return true; // break
             }
+
+            // Mark input source as consumed
+            consumedSources.push(data.sources[srcIndex]);
           });
-          if (tx.sources) { // add source output
-            addSources(data, tx.sources);
+
+          // Some input source not exist: mark as error
+          if (!validInputs) {
+            console.error("[tx] Pending TX '{}' use an unknown source as input: mark as error".format(tx.hash))
+            txErrors.push(tx);
+          }
+
+          // All tx inputs are valid
+          else {
+            // Add tx outputs has new sources
+            if (tx.sources) {
+              addSources(data, tx.sources);
+            }
+            delete tx.sources;
+            delete tx.inputs;
+
+            balanceWithPending += tx.amount; // update balance
+            txPendings.push(tx);
+            _.forEach(consumedSources, function(src) {
+              src.consumed = true;
+            });
           }
-          delete tx.sources;
-          delete tx.inputs;
-        }
-        if (valid) {
-          balanceWithPending += tx.amount; // update balance
-          txPendings.push(tx);
-          _.forEach(consumedSources, function(src) {
-            src.consumed=true;
-          });
-        }
-        else {
-          txErrors.push(tx);
         }
       }
 
@@ -376,7 +398,9 @@ angular.module('cesium.tx.services', ['ngApi', 'cesium.bma.services',
       var allTx = (data.tx.history || []).concat(data.tx.validating||[], data.tx.pendings||[], data.tx.errors||[]);
       return csWot.extendAll(allTx, 'pubkey')
         .then(function() {
-          console.debug('[tx] TX and sources loaded in {0}ms'.format(Date.now() - now));
+          console.debug('[tx] Sources and {0}TX loaded in {1}ms'.format(
+            fromTime === 'pending' ? 'pending ' : '',
+            Date.now() - now));
           return data;
         });
     })
diff --git a/www/templates/wallet/view_wallet_tx.html b/www/templates/wallet/view_wallet_tx.html
index f9d9480f2..419e89d98 100644
--- a/www/templates/wallet/view_wallet_tx.html
+++ b/www/templates/wallet/view_wallet_tx.html
@@ -152,15 +152,15 @@
             <span class="gray">{{:locale:'ACCOUNT.NO_TX'|translate}}</span>
           </span>
 
-          <!-- Fix #780: do NOT use hash ash id
+          <!-- Fix #780: do NOT use hash as id
           <div ng-repeat="tx in formData.tx.history track by tx.hash"
           -->
-          <div ng-repeat="tx in formData.tx.history track by tx.hash"
+          <div ng-repeat="tx in formData.tx.history track by tx.id"
              class="item item-tx item-icon-left"
              ng-include="::!tx.isUD ? 'templates/wallet/item_tx.html' : 'templates/wallet/item_ud.html'">
           </div>
 
-          <div class="item item-text-wrap text-center" ng-if="!formData.tx.canFetchMore && formData.tx.fromTime > 0">
+          <div class="item item-text-wrap text-center" ng-if="!canScrollMore && formData.tx.fromTime > 0">
             <p>
               <a ng-click="showMoreTx()">{{:locale:'ACCOUNT.SHOW_MORE_TX'|translate}}</a>
               <span class="gray" translate="ACCOUNT.TX_FROM_DATE" translate-values="{fromTime: formData.tx.fromTime}"></span>
@@ -172,7 +172,7 @@
         </div>
 
         <ion-infinite-scroll
-          ng-if="formData.tx.canFetchMore"
+          ng-if="canScrollMore"
           spinner="android"
           on-infinite="showMoreTx()"
           distance="20%">
-- 
GitLab