dev
dart 877 lines 24.4 KB
Raw
1 import 'dart:async';
2 import 'dart:convert';
3 import 'dart:io';
4 import 'dart:typed_data';
5 import 'package:bitcoin_base/bitcoin_base.dart';
6 import 'package:cw_bitcoin/bitcoin_amount_format.dart';
7 import 'package:cw_core/utils/print_verbose.dart';
8 import 'package:cw_core/utils/proxy_socket/abstract.dart';
9 import 'package:cw_core/utils/proxy_wrapper.dart';
10 import 'package:flutter/foundation.dart';
11 import 'package:rxdart/rxdart.dart';
12
13 enum ConnectionStatus { connected, disconnected, connecting, failed }
14
15 String jsonrpcparams(List<Object> params) {
16 final _params = params.map((val) => '"${val.toString()}"').join(',');
17 return '[$_params]';
18 }
19
20 String jsonrpc(
21 {required String method,
22 required List<Object> params,
23 required int id,
24 double version = 2.0}) =>
25 '{"jsonrpc": "$version", "method": "$method", "id": "$id", "params": ${json.encode(params)}}\n';
26
27 class SocketTask {
28 SocketTask({required this.isSubscription, this.completer, this.subject});
29
30 final Completer<dynamic>? completer;
31 final BehaviorSubject<dynamic>? subject;
32 final bool isSubscription;
33 }
34
35 class ElectrumClient {
36 ElectrumClient()
37 : _id = 0,
38 _tasks = {},
39 _errors = {},
40 unterminatedString = '';
41
42 static const connectionTimeout = Duration(seconds: 5);
43 static const aliveTimerDuration = Duration(seconds: 5);
44
45 bool get isConnected => socket != null && socket?.isClosed == false;
46 ProxySocket? socket;
47 void Function(ConnectionStatus)? onConnectionStatusChange;
48 int _id;
49 final Map<String, SocketTask> _tasks;
50 Map<String, SocketTask> get tasks => _tasks;
51 final Map<String, String> _errors;
52 ConnectionStatus _connectionStatus = ConnectionStatus.disconnected;
53 Timer? _aliveTimer;
54 String unterminatedString;
55
56 Uri? uri;
57 bool? useSSL;
58
59 Future<void> connectToUri(Uri uri, {bool? useSSL}) async {
60 this.uri = uri;
61 if (useSSL != null) {
62 this.useSSL = useSSL;
63 }
64 await connect(host: uri.host, port: uri.port);
65 }
66
67 Future<void> connect({required String host, required int port}) async {
68 _setConnectionStatus(ConnectionStatus.connecting);
69
70 // Reset internal state to ensure clean connection
71 _resetInternalState();
72
73 try {
74 await socket?.close();
75 } catch (_) {}
76 socket = null;
77
78 final ssl = !(useSSL == false || (useSSL == null && uri.toString().contains("btc-electrum")));
79 try {
80 socket = await ProxyWrapper()
81 .getSocksSocket(ssl, host, port, connectionTimeout: connectionTimeout);
82 } catch (e) {
83 printV("connect: $e");
84 if (e is HandshakeException) {
85 useSSL = !(useSSL ?? false);
86 }
87
88 if (_connectionStatus != ConnectionStatus.connecting) {
89 _setConnectionStatus(ConnectionStatus.failed);
90 }
91
92 return;
93 }
94
95 if (socket == null) {
96 if (_connectionStatus != ConnectionStatus.connecting) {
97 _setConnectionStatus(ConnectionStatus.failed);
98 }
99
100 return;
101 }
102
103 // use ping to determine actual connection status since we could've just not timed out yet:
104 // _setConnectionStatus(ConnectionStatus.connected);
105 socket!.listen(
106 (Uint8List event) {
107 try {
108 final msg = utf8.decode(event.toList());
109 final messagesList = msg.split("\n");
110 for (var message in messagesList) {
111 if (message.isEmpty) {
112 continue;
113 }
114 _parseResponse(message);
115 }
116 } catch (e) {
117 printV("socket.listen: $e");
118 }
119 },
120 onError: (Object error) {
121 final errorMsg = error.toString();
122 printV(errorMsg);
123 unterminatedString = '';
124 socket?.destroy();
125 socket = null;
126 _setConnectionStatus(ConnectionStatus.disconnected);
127 },
128 onDone: () {
129 printV("SOCKET CLOSED!!!!!");
130 unterminatedString = '';
131 try {
132 _setConnectionStatus(ConnectionStatus.disconnected);
133 socket?.destroy();
134 socket = null;
135 } catch (e) {
136 printV("onDone: $e");
137 }
138 },
139 cancelOnError: true,
140 );
141
142 keepAlive();
143 }
144
145 void _parseResponse(String message) {
146 try {
147 final response = json.decode(message);
148 _handleResponse(response);
149 } on FormatException catch (e) {
150 final msg = e.message.toLowerCase();
151
152 if (e.source is String) {
153 unterminatedString += e.source as String;
154 }
155
156 if (msg.contains("not a subtype of type")) {
157 unterminatedString += e.source as String;
158 return;
159 }
160
161 if (isJSONStringCorrect(unterminatedString)) {
162 final response = json.decode(unterminatedString);
163 _handleResponse(response);
164 unterminatedString = '';
165 }
166 } on TypeError catch (e) {
167 if (!e.toString().contains('Map<String, Object>') &&
168 !e.toString().contains('Map<String, dynamic>')) {
169 return;
170 }
171
172 unterminatedString += message;
173
174 if (isJSONStringCorrect(unterminatedString)) {
175 final response = json.decode(unterminatedString);
176 _handleResponse(response);
177 // unterminatedString = null;
178 unterminatedString = '';
179 }
180 } catch (e) {
181 printV("parse $e");
182 }
183 }
184
185 void keepAlive() {
186 _aliveTimer?.cancel();
187 _aliveTimer = Timer.periodic(aliveTimerDuration, (_) async => ping());
188 }
189
190 Future<void> ping() async {
191 try {
192 await callWithTimeout(method: 'server.ping');
193 _setConnectionStatus(ConnectionStatus.connected);
194 } catch (_) {
195 _setConnectionStatus(ConnectionStatus.disconnected);
196 }
197 }
198
199 Future<List<String>> version() =>
200 call(method: 'server.version', params: ["", "1.4"]).then((dynamic result) {
201 if (result is List) {
202 return result.map((dynamic val) => val.toString()).toList();
203 }
204
205 return [];
206 });
207
208 Future<Map<String, dynamic>> getBalance(String scriptHash, {bool throwOnError = false}) async {
209 try {
210 final result = await call(method: 'blockchain.scripthash.get_balance', params: [scriptHash]);
211 if (result is Map<String, dynamic>) {
212 return result;
213 }
214
215 if (throwOnError) {
216 throw Exception('Invalid response format for getBalance');
217 }
218
219 return <String, dynamic>{};
220 } catch (e) {
221 if (throwOnError) {
222 rethrow;
223 }
224 return <String, dynamic>{};
225 }
226 }
227
228 Future<List<Map<String, dynamic>>> getHistory(String scriptHash) =>
229 call(method: 'blockchain.scripthash.get_history', params: [scriptHash])
230 .then((dynamic result) {
231 if (result is List) {
232 return result.map((dynamic val) {
233 if (val is Map<String, dynamic>) {
234 return val;
235 }
236
237 return <String, dynamic>{};
238 }).toList();
239 }
240
241 return [];
242 });
243
244 Future<List<Map<String, dynamic>>?> getListUnspent(String scriptHash) async {
245 final result = await call(method: 'blockchain.scripthash.listunspent', params: [scriptHash]);
246
247 if (result is List) {
248 return result.map((dynamic val) {
249 if (val is Map<String, dynamic>) {
250 return val;
251 }
252
253 return <String, dynamic>{};
254 }).toList();
255 }
256
257 return null;
258 }
259
260 Future<List<Map<String, dynamic>>> getMempool(String scriptHash) =>
261 call(method: 'blockchain.scripthash.get_mempool', params: [scriptHash])
262 .then((dynamic result) {
263 if (result is List) {
264 return result.map((dynamic val) {
265 if (val is Map<String, dynamic>) {
266 return val;
267 }
268
269 return <String, dynamic>{};
270 }).toList();
271 }
272
273 return [];
274 });
275
276 Future<dynamic> getTransaction({required String hash, required bool verbose}) async {
277 try {
278 final result = await callWithTimeout(
279 method: 'blockchain.transaction.get', params: [hash, verbose], timeout: 10000);
280 return result;
281 } on RequestFailedTimeoutException catch (_) {
282 return <String, dynamic>{};
283 } catch (e) {
284 return <String, dynamic>{};
285 }
286 }
287
288 Future<Map<String, dynamic>> getTransactionVerbose({required String hash}) =>
289 getTransaction(hash: hash, verbose: true).then((dynamic result) {
290 if (result is Map<String, dynamic>) {
291 return result;
292 }
293
294 return <String, dynamic>{};
295 });
296
297 Future<String> getTransactionHex({required String hash}) =>
298 getTransaction(hash: hash, verbose: false).then((dynamic result) {
299 if (result is String) {
300 return result;
301 }
302
303 return '';
304 });
305
306 Future<Map<String, List<Map<String, dynamic>>>> getBatchHistory(
307 List<String> scriptHashes, {
308 int timeout = 10000,
309 }) async {
310 final paramsList = scriptHashes.map((h) => <Object>[h]).toList(growable: false);
311
312 final batchResults = await callBatchWithTimeout(
313 method: 'blockchain.scripthash.get_history',
314 paramsList: paramsList,
315 timeout: timeout,
316 );
317
318 final historyMap = <String, List<Map<String, dynamic>>>{};
319
320 for (int i = 0; i < scriptHashes.length; i++) {
321 final sh = scriptHashes[i];
322
323 if (i >= batchResults.length) {
324 historyMap[sh] = const [];
325 continue;
326 }
327
328 final result = batchResults[i];
329
330 if (result is List) {
331 historyMap[sh] = result
332 .whereType<Map<dynamic, dynamic>>()
333 .map((m) => m.map((k, v) => MapEntry(k.toString(), v)))
334 .cast<Map<String, dynamic>>()
335 .toList();
336 } else {
337 historyMap[sh] = const [];
338 }
339 }
340
341 return historyMap;
342 }
343
344 Future<Map<String, List<Map<String, dynamic>>>> getBatchUnspent(
345 List<String> scriptHashes, {
346 int timeout = 10000,
347 }) async {
348 final paramsList = scriptHashes.map((h) => <Object>[h]).toList(growable: false);
349
350 final batchResults = await callBatchWithTimeout(
351 method: 'blockchain.scripthash.listunspent',
352 paramsList: paramsList,
353 timeout: timeout,
354 );
355
356 final unspentMap = <String, List<Map<String, dynamic>>>{};
357
358 for (int i = 0; i < scriptHashes.length; i++) {
359 final sh = scriptHashes[i];
360
361 if (i >= batchResults.length) {
362 unspentMap[sh] = const [];
363 continue;
364 }
365
366 final result = batchResults[i];
367
368 if (result is List) {
369 unspentMap[sh] = result
370 .whereType<Map<dynamic, dynamic>>()
371 .map((m) => m.map((k, v) => MapEntry(k.toString(), v)))
372 .cast<Map<String, dynamic>>()
373 .toList();
374 } else {
375 unspentMap[sh] = const [];
376 }
377 }
378
379 return unspentMap;
380 }
381
382 Future<Map<String, Map<String, dynamic>>> getBatchBalance(
383 List<String> scriptHashes, {
384 int timeout = 10000,
385 }) async {
386 final paramsList = scriptHashes.map((h) => <Object>[h]).toList(growable: false);
387
388 final batchResults = await callBatchWithTimeout(
389 method: 'blockchain.scripthash.get_balance',
390 paramsList: paramsList,
391 timeout: timeout,
392 );
393
394 final balanceMap = <String, Map<String, dynamic>>{};
395
396 for (int i = 0; i < scriptHashes.length; i++) {
397 final sh = scriptHashes[i];
398
399 if (i >= batchResults.length) {
400 balanceMap[sh] = <String, dynamic>{};
401 continue;
402 }
403
404 final result = batchResults[i];
405
406 if (result is Map<String, dynamic>) {
407 balanceMap[sh] = result;
408 } else if (result is Map) {
409 balanceMap[sh] = Map<String, dynamic>.from(result);
410 } else {
411 balanceMap[sh] = <String, dynamic>{};
412 }
413 }
414
415 return balanceMap;
416 }
417
418 Future<Map<String, Map<String, dynamic>>> getBatchTransactionVerbose(
419 List<String> hashes, {
420 int timeout = 10000,
421 }) async {
422 final result = <String, Map<String, dynamic>>{};
423 if (hashes.isEmpty) return result;
424
425 final paramsList = hashes.map((h) => <Object>[h, true]).toList(growable: false);
426 final batchResults = await callBatchWithTimeout(
427 method: 'blockchain.transaction.get',
428 paramsList: paramsList,
429 timeout: timeout,
430 );
431
432 for (var i = 0; i < hashes.length; i++) {
433 final txid = hashes[i];
434 final r = (i < batchResults.length) ? batchResults[i] : null;
435 if (r is Map<String, dynamic>) {
436 result[txid] = r;
437 } else {
438 result[txid] = <String, dynamic>{};
439 }
440 }
441
442 return result;
443 }
444
445 Future<Map<String, String?>> getBatchTransactionHex(
446 List<String> hashes, {
447 int timeout = 10000,
448 }) async {
449 final result = <String, String?>{};
450 if (hashes.isEmpty) return result;
451
452 final paramsList = hashes.map((h) => <Object>[h]).toList(growable: false);
453 final batchResults = await callBatchWithTimeout(
454 method: 'blockchain.transaction.get',
455 paramsList: paramsList,
456 timeout: timeout,
457 );
458
459 for (var i = 0; i < hashes.length; i++) {
460 final txid = hashes[i];
461 final r = (i < batchResults.length) ? batchResults[i] : null;
462 if (r is String && r.isNotEmpty) {
463 result[txid] = r;
464 } else {
465 result[txid] = null;
466 }
467 }
468
469 return result;
470 }
471
472 Future<List<dynamic>> callBatchWithTimeout({
473 required String method,
474 required List<List<Object>> paramsList,
475 int timeout = 10000,
476 }) async {
477 if (!isConnected) return [];
478
479 final completer = Completer<List<dynamic>>();
480 final int batchBaseId = _id += 1;
481 final String internalBatchKey = "batch_$batchBaseId";
482
483 // Build the Batch Array
484 final List<Map<String, dynamic>> batchPayload = [];
485 for (int i = 0; i < paramsList.length; i++) {
486 batchPayload.add(
487 {"jsonrpc": "2.0", "method": method, "params": paramsList[i], "id": "$batchBaseId-$i"});
488 }
489
490 // Register the task
491 _tasks[internalBatchKey] = SocketTask(completer: completer, isSubscription: false);
492
493 // Write to socket
494 socket!.write(json.encode(batchPayload) + "\n");
495
496 // Timeout Logic
497 Timer(Duration(milliseconds: timeout), () {
498 if (!completer.isCompleted) {
499 _tasks.remove(internalBatchKey);
500 completer.completeError(RequestFailedTimeoutException("BATCH_$method", batchBaseId));
501 }
502 });
503
504 return completer.future;
505 }
506
507 Future<String> broadcastTransaction(
508 {required String transactionRaw,
509 BasedUtxoNetwork? network,
510 Function(int)? idCallback}) async =>
511 call(
512 method: 'blockchain.transaction.broadcast',
513 params: [transactionRaw],
514 idCallback: idCallback)
515 .then((dynamic result) {
516 if (result is String) {
517 return result;
518 }
519
520 return '';
521 });
522
523 Future<Map<String, dynamic>> getMerkle({required String hash, required int height}) async =>
524 await call(method: 'blockchain.transaction.get_merkle', params: [hash, height])
525 as Map<String, dynamic>;
526
527 Future<Map<String, dynamic>> getHeader({required int height}) async =>
528 await call(method: 'blockchain.block.get_header', params: [height]) as Map<String, dynamic>;
529
530 BehaviorSubject<Object>? tweaksSubscribe({required int height, required int count}) {
531 return subscribe<Object>(
532 id: 'blockchain.tweaks.subscribe',
533 method: 'blockchain.tweaks.subscribe',
534 params: [height, count, false],
535 );
536 }
537
538 Future<dynamic> getTweaks({required int height}) async =>
539 await callWithTimeout(method: 'blockchain.tweaks.subscribe', params: [height, 1, false]);
540
541 Future<double> estimatefee({required int p}) =>
542 call(method: 'blockchain.estimatefee', params: [p]).then((dynamic result) {
543 if (result is double) {
544 return result;
545 }
546
547 if (result is String) {
548 return double.parse(result);
549 }
550
551 return 0;
552 });
553
554 Future<List<List<int>>> feeHistogram() =>
555 call(method: 'mempool.get_fee_histogram').then((dynamic result) {
556 if (result is List) {
557 // return result.map((dynamic e) {
558 // if (e is List) {
559 // return e.map((dynamic ee) => ee is int ? ee : null).toList();
560 // }
561
562 // return null;
563 // }).toList();
564 final histogram = <List<int>>[];
565 for (final e in result) {
566 if (e is List) {
567 final eee = <int>[];
568 for (final ee in e) {
569 if (ee is int) {
570 eee.add(ee);
571 }
572 }
573 histogram.add(eee);
574 }
575 }
576 return histogram;
577 }
578
579 return [];
580 });
581
582 // Floor at 0 so unavailable/-1 estimates never become negative rates;
583 // cap at 2000 sat/vB per CW-1597.
584 static const int _maxFeeRate = 2000;
585
586 static int _sanitizeFeeRate(double feeRate) {
587 final rate = (stringDoubleToBitcoinAmount(feeRate.toString()) / 1000).round();
588 if (rate < 0) {
589 return 0;
590 }
591 if (rate > _maxFeeRate) {
592 return _maxFeeRate;
593 }
594 return rate;
595 }
596
597 Future<List<int>> feeRates({BasedUtxoNetwork? network}) async {
598 try {
599 final topDouble = await estimatefee(p: 1);
600 final middleDouble = await estimatefee(p: 5);
601 final bottomDouble = await estimatefee(p: 10);
602 final top = _sanitizeFeeRate(topDouble);
603 final middle = _sanitizeFeeRate(middleDouble);
604 final bottom = _sanitizeFeeRate(bottomDouble);
605
606 return [bottom, middle, top];
607 } catch (_) {
608 return [];
609 }
610 }
611
612 // https://electrumx.readthedocs.io/en/latest/protocol-methods.html#blockchain-headers-subscribe
613 // example response:
614 // {
615 // "height": 520481,
616 // "hex": "00000020890208a0ae3a3892aa047c5468725846577cfcd9b512b50000000000000000005dc2b02f2d297a9064ee103036c14d678f9afc7e3d9409cf53fd58b82e938e8ecbeca05a2d2103188ce804c4"
617 // }
618
619 Future<int?> getCurrentBlockChainTip() async {
620 try {
621 final result = await callWithTimeout(method: 'blockchain.headers.subscribe');
622 if (result is Map<String, dynamic>) {
623 return result["height"] as int;
624 }
625 return null;
626 } on RequestFailedTimeoutException catch (_) {
627 return null;
628 } catch (e) {
629 printV("getCurrentBlockChainTip: ${e.toString()}");
630 return null;
631 }
632 }
633
634 BehaviorSubject<Object>? chainTipSubscribe() {
635 _id += 1;
636 return subscribe<Object>(
637 id: 'blockchain.headers.subscribe', method: 'blockchain.headers.subscribe');
638 }
639
640 BehaviorSubject<Object>? scripthashUpdate(String scripthash) {
641 _id += 1;
642 return subscribe<Object>(
643 id: 'blockchain.scripthash.subscribe:$scripthash',
644 method: 'blockchain.scripthash.subscribe',
645 params: [scripthash]);
646 }
647
648 BehaviorSubject<T>? subscribe<T>(
649 {required String id, required String method, List<Object> params = const []}) {
650 try {
651 if (socket == null) {
652 return null;
653 }
654 final subscription = BehaviorSubject<T>();
655 _regisrySubscription(id, subscription);
656 socket!.write(jsonrpc(method: method, id: _id, params: params));
657
658 return subscription;
659 } catch (e) {
660 printV("subscribe $e");
661 return null;
662 }
663 }
664
665 Future<dynamic> call(
666 {required String method, List<Object> params = const [], Function(int)? idCallback}) async {
667 if (!isConnected) return null;
668
669 final completer = Completer<dynamic>();
670 _id += 1;
671 final id = _id;
672 idCallback?.call(id);
673 _registryTask(id, completer);
674 socket!.write(jsonrpc(method: method, id: id, params: params));
675
676 return completer.future;
677 }
678
679 Future<dynamic> callWithTimeout(
680 {required String method, List<Object> params = const [], int timeout = 5000}) async {
681 try {
682 if (!isConnected) return null;
683
684 final completer = Completer<dynamic>();
685 _id += 1;
686 final id = _id;
687 _registryTask(id, completer);
688 socket!.write(jsonrpc(method: method, id: id, params: params));
689 Timer(Duration(milliseconds: timeout), () {
690 if (!completer.isCompleted) {
691 completer.completeError(RequestFailedTimeoutException(method, id));
692 }
693 });
694
695 return completer.future;
696 } catch (e) {
697 printV("callWithTimeout $e");
698 rethrow;
699 }
700 }
701
702 Future<void> close() async {
703 _aliveTimer?.cancel();
704 try {
705 await socket?.close();
706 socket = null;
707 } catch (_) {}
708 onConnectionStatusChange = null;
709 // Reset internal state when closing
710 _resetInternalStateCompletely();
711 }
712
713 void _resetInternalState() {
714 // Only clears errors and unterminated string, leaves tasks or reset ID
715 // This preserves active subscriptions while clearing error state
716 _errors.clear();
717 unterminatedString = '';
718 }
719
720 void _resetInternalStateCompletely() {
721 _id = 0;
722 _tasks.clear();
723 _errors.clear();
724 unterminatedString = '';
725 }
726
727 void _registryTask(int id, Completer<dynamic> completer) =>
728 _tasks[id.toString()] = SocketTask(completer: completer, isSubscription: false);
729
730 void _regisrySubscription(String id, BehaviorSubject<dynamic> subject) =>
731 _tasks[id] = SocketTask(subject: subject, isSubscription: true);
732
733 void _finish(String id, Object? data) {
734 if (_tasks[id] == null) {
735 return;
736 }
737
738 if (!(_tasks[id]?.completer?.isCompleted ?? false)) {
739 _tasks[id]?.completer!.complete(data);
740 }
741
742 if (!(_tasks[id]?.isSubscription ?? false)) {
743 _tasks.remove(id);
744 } else {
745 _tasks[id]?.subject?.add(data);
746 }
747 }
748
749 void _methodHandler({required String method, required Map<String, dynamic> request}) {
750 switch (method) {
751 case 'blockchain.headers.subscribe':
752 final params = request['params'] as List<dynamic>;
753 final id = 'blockchain.headers.subscribe';
754
755 _tasks[id]?.subject?.add(params.last);
756 break;
757 case 'blockchain.scripthash.subscribe':
758 final params = request['params'] as List<dynamic>;
759 final scripthash = params.first as String?;
760 final id = 'blockchain.scripthash.subscribe:$scripthash';
761
762 _tasks[id]?.subject?.add(params.last);
763 break;
764 case 'blockchain.headers.subscribe':
765 final params = request['params'] as List<dynamic>;
766 _tasks[method]?.subject?.add(params.last);
767 break;
768 case 'blockchain.tweaks.subscribe':
769 final params = request['params'] as List<dynamic>;
770 _tasks[_tasks.keys.first]?.subject?.add(params.last);
771 break;
772 default:
773 break;
774 }
775 }
776
777 void _setConnectionStatus(ConnectionStatus status) {
778 onConnectionStatusChange?.call(status);
779 _connectionStatus = status;
780 if (!isConnected) {
781 try {
782 socket?.destroy();
783 } catch (_) {}
784 socket = null;
785 }
786 }
787
788 void _handleResponse(dynamic response) {
789 // Handle batch response
790 if (response is List) {
791 if (response.isEmpty) return;
792
793 // Sort responses by ID to ensure correct order for batch processing
794 response.sort((a, b) {
795 try {
796 final idA = int.parse(a['id'].toString().split('-').last);
797 final idB = int.parse(b['id'].toString().split('-').last);
798 return idA.compareTo(idB);
799 } catch (_) {
800 return 0;
801 }
802 });
803
804 final firstItem = response.first as Map<String, dynamic>;
805 final String firstIdAttr = firstItem['id'].toString();
806
807 final String batchKey = firstIdAttr.contains('-')
808 ? "batch_${firstIdAttr.split('-')[0].replaceAll('batch_', '')}"
809 : firstIdAttr;
810
811 // Extract the results from each item in the batch
812 final results = response.map((item) {
813 if (item is Map) {
814 return item['result'] ?? item['error'];
815 }
816 return null;
817 }).toList();
818
819 _finish(batchKey, results);
820 return;
821 }
822
823 // Handle single response
824 if (response is Map<String, dynamic>) {
825 final method = response['method'];
826 final id = response['id'] as String?;
827 final result = response['result'];
828
829 try {
830 final error = response['error'] as Map<String, dynamic>?;
831 if (error != null) {
832 final errorMessage = error['message'] as String?;
833 if (errorMessage != null) {
834 _errors[id!] = errorMessage;
835 }
836 }
837 } catch (_) {}
838
839 try {
840 final error = response['error'] as String?;
841 if (error != null) {
842 _errors[id!] = error;
843 }
844 } catch (_) {}
845
846 if (method is String) {
847 _methodHandler(method: method, request: response);
848 return;
849 }
850
851 if (id != null) {
852 _finish(id, result);
853 }
854 }
855 }
856
857 String getErrorMessage(int id) => _errors[id.toString()] ?? '';
858
859 bool get isInternalStateConsistent => _errors.isEmpty;
860 }
861
862 // FIXME: move me
863 bool isJSONStringCorrect(String source) {
864 try {
865 json.decode(source);
866 return true;
867 } catch (_) {
868 return false;
869 }
870 }
871
872 class RequestFailedTimeoutException implements Exception {
873 RequestFailedTimeoutException(this.method, this.id);
874
875 final String method;
876 final int id;
877 }