Line data Source code
1 : import 'dart:convert';
2 : import 'dart:math';
3 : import 'package:walletkit_dart/src/common/http_client.dart';
4 : import 'package:walletkit_dart/src/common/logger.dart';
5 :
6 : class EtherscanRepository {
7 : final String baseUrl;
8 : final List<String> apiKeys;
9 : final Map<String, bool> endpointNeedsApiKey = {};
10 : final Map<String, DateTime> apiKeyExcludedUntil = {};
11 : final List<String> invalidApiKeys = [];
12 :
13 : final Duration noApiKeyRetryIntervall;
14 : final Duration apiKeyRetryIntervall;
15 :
16 1 : EtherscanRepository(
17 : this.baseUrl,
18 : this.apiKeys, {
19 : this.noApiKeyRetryIntervall = const Duration(seconds: 5),
20 : this.apiKeyRetryIntervall = const Duration(seconds: 3),
21 : });
22 :
23 1 : String? _getRandomApiKey() {
24 2 : if (apiKeys.isEmpty) return null;
25 1 : final now = DateTime.now();
26 3 : final availableKeys = apiKeys.where((key) {
27 2 : if (invalidApiKeys.contains(key)) return false;
28 2 : final excludedUntil = apiKeyExcludedUntil[key];
29 :
30 0 : return excludedUntil == null || now.isAfter(excludedUntil);
31 1 : }).toList();
32 1 : if (availableKeys.isEmpty) return null;
33 4 : return availableKeys[Random().nextInt(availableKeys.length)];
34 : }
35 :
36 1 : bool _needsApiKey(String endpoint) {
37 2 : return endpointNeedsApiKey[endpoint] ?? false;
38 : }
39 :
40 1 : void _setNeedsApiKey(String endpoint, bool needs) {
41 2 : endpointNeedsApiKey[endpoint] = needs;
42 : }
43 :
44 0 : void _excludeApiKey(String apiKey) {
45 0 : Logger.log("Excluding API key $apiKey for 1 hour");
46 0 : apiKeyExcludedUntil[apiKey] = DateTime.now().add(Duration(hours: 1));
47 : }
48 :
49 2 : Map<String, String> _buildRequestHeaders() => {'Content-Type': 'application/json'};
50 :
51 1 : String getBaseEtherscanEndpoint(String fullUrl) {
52 1 : Uri uri = Uri.parse(fullUrl);
53 :
54 : // Extract the scheme, host, and path
55 4 : String baseUrl = '${uri.scheme}://${uri.host}${uri.path}';
56 :
57 : // Get the query parameters
58 1 : Map<String, String> queryParams = uri.queryParameters;
59 :
60 : // Check if 'module' and 'action' parameters exist
61 2 : if (queryParams.containsKey('module') && queryParams.containsKey('action')) {
62 1 : String module = queryParams['module']!;
63 1 : String action = queryParams['action']!;
64 :
65 : // Construct the base endpoint
66 1 : return '$baseUrl&module=$module&action=$action';
67 : } else {
68 : // If 'module' or 'action' is missing, return the original URL
69 : return fullUrl;
70 : }
71 : }
72 :
73 1 : Future<T> fetchEtherscanWithRatelimitRetries<T>(
74 : String rawEndpoint, {
75 : int maxRetries = 10,
76 : }) async {
77 1 : final baseEndpoint = getBaseEtherscanEndpoint(rawEndpoint);
78 :
79 : bool maybeUseApiKey = false;
80 :
81 2 : for (var i = 0; i < maxRetries; i++) {
82 : String endpoint = rawEndpoint;
83 : String? currentApiKey;
84 :
85 1 : if (_needsApiKey(baseEndpoint)) {
86 : maybeUseApiKey = false;
87 1 : currentApiKey = _getRandomApiKey();
88 : if (currentApiKey == null) {
89 0 : Logger.logError("No available API keys");
90 0 : throw Exception("No available API keys");
91 : }
92 1 : endpoint = "$rawEndpoint&apikey=$currentApiKey";
93 : } else if (maybeUseApiKey) {
94 : maybeUseApiKey = false;
95 0 : currentApiKey = _getRandomApiKey();
96 : if (currentApiKey != null) {
97 0 : endpoint = "$rawEndpoint&apikey=$currentApiKey";
98 : }
99 : }
100 :
101 2 : final response = await HTTPService.client.get(
102 1 : Uri.parse(endpoint),
103 1 : headers: _buildRequestHeaders(),
104 : );
105 :
106 2 : if (response.statusCode == 200) {
107 2 : final body = jsonDecode(response.body);
108 2 : int status = int.tryParse(body['status'] ?? '') ?? -1;
109 1 : final result = body['result'];
110 :
111 1 : if (status == 1) return result;
112 :
113 1 : if (status == 0) {
114 1 : final result_s = result is String ? result : 'empty';
115 : if (result == null) {
116 : continue;
117 : }
118 :
119 1 : if (result == "Missing/Invalid API Key") {
120 1 : _setNeedsApiKey(baseEndpoint, true);
121 1 : } else if (result_s.contains('Invalid API Key')) {
122 0 : invalidApiKeys.add(currentApiKey!);
123 0 : if (_getRandomApiKey() == null) {
124 0 : await Future.delayed(noApiKeyRetryIntervall);
125 : } else {
126 : maybeUseApiKey = true; // Try again with an API key
127 : }
128 1 : } else if (result_s.contains("Max daily rate limit")) {
129 : if (currentApiKey != null) {
130 0 : _excludeApiKey(currentApiKey);
131 : }
132 0 : if (_getRandomApiKey() == null) {
133 0 : await Future.delayed(noApiKeyRetryIntervall);
134 : } else {
135 : maybeUseApiKey = true; // Try again with an API key
136 : }
137 1 : } else if (result_s.contains('for higher rate limit')) {
138 0 : if (_getRandomApiKey() == null) {
139 0 : await Future.delayed(noApiKeyRetryIntervall);
140 : } else {
141 : maybeUseApiKey = true; // Try again with an API key
142 : }
143 1 : } else if (result_s.contains("Max calls per sec")) {
144 2 : await Future.delayed(apiKeyRetryIntervall);
145 : } else {
146 1 : String message = body['message'];
147 1 : if (message != "NOTOK") return result;
148 : }
149 : }
150 : }
151 : }
152 :
153 0 : throw Exception("Failed to fetch $rawEndpoint after $maxRetries retries");
154 : }
155 : }
|