dev
dart 702 lines 20.6 KB
Raw
1 import 'dart:convert';
2 import 'dart:io';
3 import 'dart:math' as math;
4
5 import 'package:blockchain_utils/hex/hex.dart';
6 import 'package:cw_core/node_list.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:cw_core/wallet_type.dart';
11 import 'package:crypto/crypto.dart';
12 import 'package:sqflite/sqflite.dart';
13
14 import 'db/sqlite.dart';
15
16 Uri createUriFromElectrumAddress(String address, String path) =>
17 Uri.tryParse('tcp://$address$path')!;
18
19 Future<void> validateBuiltinNodes() async {
20 // ensures nodes stored as builtin in the db correspond to the nodes in the .yml files
21
22 final builtinFromDb = await Node.getAllBuiltin();
23 final builtinFromList = await loadAllDefaultNodes();
24
25 for (final listNode in builtinFromList) {
26 // preserve proxy settings from user
27 try {
28 final matchedDbNode = builtinFromDb.firstWhere((dbNode) => dbNode.uri == listNode.uri);
29 listNode.socksProxyAddress = matchedDbNode.socksProxyAddress;
30 } catch (e) {}
31 }
32
33 final dbSet = builtinFromDb.toSet();
34 final listSet = builtinFromList.toSet();
35
36 final nodesToDelete = dbSet.difference(listSet);
37 final nodesToAdd = listSet.difference(dbSet);
38
39 for (final node in nodesToDelete) await node.delete();
40 for (final node in nodesToAdd) await node.save();
41 }
42
43 class Node {
44 Node({
45 this.id = 0,
46 this.label,
47 this.login,
48 this.password,
49 this.useSSL,
50 this.isPow = false,
51 this.trusted = false,
52 this.socksProxyAddress,
53 this.path = '',
54 this.isEnabledForAutoSwitching = false,
55 this.isOfficial = false,
56 this.isBuiltin = false,
57 this.isDefault = false,
58 String? uri,
59 WalletType? type,
60 }) {
61 if (uri != null) {
62 uriRaw = uri;
63 }
64 if (type != null) {
65 this.type = type;
66 }
67 }
68
69 @override
70 String toString() {
71 return """Node(
72 uriRaw: $uriRaw,
73 path: $path,
74 login: $login,
75 password: $password,
76 useSSL: $useSSL,
77 trusted: $trusted,
78 socksProxyAddress: $socksProxyAddress,
79 isEnabledForAutoSwitching: $isEnabledForAutoSwitching,
80 isOfficial: $isOfficial,
81 isBuiltin: $isBuiltin,
82 isDefault: $isDefault
83 })""";
84 }
85
86 Node.fromMap(Map<String, Object?> map)
87 : id = (map[selfIdColumn] ?? 0) as int,
88 uriRaw = map['uri'] as String? ?? '',
89 path = map['path'] as String? ?? '',
90 login = map['login'] as String?,
91 label = map['label'] as String?,
92 password = map['password'] as String?,
93 isPow = _getBoolFromDB(map['isPow']),
94 useSSL = _getBoolFromDB(map['useSSL']),
95 typeRaw = (map["typeRaw"] ?? 0) as int,
96 trusted = _getBoolFromDB(map['trusted']),
97 socksProxyAddress = map['socksProxyAddress'] as String?,
98 isEnabledForAutoSwitching = _getBoolFromDB(map['isEnabledForAutoSwitching']),
99 isOfficial = _getBoolFromDB(map['isOfficial']),
100 isBuiltin = _getBoolFromDB(map['isBuiltin']),
101 isDefault = _getBoolFromDB(map['isDefault']);
102
103 static bool _getBoolFromDB(value, {bool? defaultValue}) {
104 if (value is bool) {
105 return value;
106 } else if (value is int) {
107 return value == 1;
108 } else {
109 return defaultValue ?? false;
110 }
111 }
112
113 Map<String, dynamic> toMap() {
114 return {
115 selfIdColumn: id,
116 'uri': uriRaw,
117 'path': path,
118 'login': login,
119 "label": label,
120 'password': password,
121 "isPow": isPow ? 1 : 0,
122 'useSSL': (useSSL ?? false) ? 1 : 0,
123 "typeRaw": typeRaw,
124 'trusted': trusted ? 1 : 0,
125 'socksProxyAddress': socksProxyAddress,
126 'isEnabledForAutoSwitching': isEnabledForAutoSwitching ? 1 : 0,
127 "isOfficial": isOfficial ? 1 : 0,
128 "isBuiltin": isBuiltin ? 1 : 0,
129 "isDefault": isDefault ? 1 : 0
130 };
131 }
132
133 Node.fromUri(Uri uri, WalletType type)
134 : id = 0,
135 uriRaw =
136 "${uri.host}${uri.hasPort ? ':${uri.port}' : (uri.queryParameters['port']?.isNotEmpty == true ? ':${uri.queryParameters['port']}' : '')}",
137 path = uri.path,
138 login = uri.userInfo.contains(':')
139 ? uri.userInfo.split(':')[0]
140 : uri.queryParameters['username'],
141 password = uri.userInfo.contains(':')
142 ? uri.userInfo.split(':')[1]
143 : uri.queryParameters['password'],
144 useSSL = uri.queryParameters['protocol'] == 'https',
145 trusted = uri.queryParameters['trusted'] == 'true',
146 isPow = false,
147 isEnabledForAutoSwitching = false,
148 isOfficial = false,
149 isBuiltin = false,
150 isDefault = false,
151 typeRaw = serializeToInt(type);
152
153 Future<int> delete() async {
154 return await db!.delete(tableName, where: '${selfIdColumn} = ?', whereArgs: [id]);
155 }
156
157 static Future<int> deleteAll() async {
158 return await db!.delete(tableName, where: "isPow = ?", whereArgs: [0]);
159 }
160
161 static Future<int> deleteAllPow() async {
162 return await db!.delete(tableName, where: "isPow = ?", whereArgs: [1]);
163 }
164
165 Future<int> save() async {
166 final json = toMap();
167 if (json[selfIdColumn] == 0) {
168 json[selfIdColumn] = null;
169 }
170 id = await db!.insert(tableName, json, conflictAlgorithm: ConflictAlgorithm.replace);
171 return id;
172 }
173
174 static Future<List<Node>> selectList(String where, List<dynamic> whereArgs,
175 {String? orderBy}) async {
176 if (orderBy == null) {
177 orderBy = selfIdColumn;
178 }
179 final list = await db!.query(
180 tableName,
181 where: where.isNotEmpty ? where : "1 = 1",
182 whereArgs: whereArgs.isNotEmpty ? whereArgs : null,
183 orderBy: orderBy,
184 );
185 return List.generate(list.length, (index) => Node.fromMap(list[index]));
186 }
187
188 static Future<Node?> select(String where, List<dynamic> whereArgs, {String? orderBy}) async {
189 if (orderBy == null) {
190 orderBy = selfIdColumn;
191 }
192 final list = await db!.query(
193 tableName,
194 where: where.isNotEmpty ? where : "1 = 1",
195 whereArgs: whereArgs.isNotEmpty ? whereArgs : null,
196 orderBy: orderBy,
197 );
198 return list.isEmpty ? null : Node.fromMap(list.first);
199 }
200
201 static Future<List<Node>> getAll() async {
202 return selectList("isPow = ?", [0]);
203 }
204
205 static Future<List<Node>> getAllBuiltin() async {
206 return selectList("isPow = ? AND isBuiltin = ?", [0, 1]);
207 }
208
209 static Future<List<Node>> getAllPowBuiltin() async {
210 return selectList("isPow = ? AND isBuiltin = ?", [1, 1]);
211 }
212
213 static Future<List<Node>> getAllForWalletType(WalletType type) async {
214 return selectList("typeRaw = ? AND isPow = ?", [serializeToInt(type), 0]);
215 }
216
217 static Future<Node?> getDefaultForWalletType(WalletType type) async {
218 return (await selectList(
219 "typeRaw = ? AND isPow = ? AND isDefault = ?", [serializeToInt(type), 0, 1]))
220 .firstOrNull;
221 }
222
223 static Future<Node?> getDefaultPowForWalletType(WalletType type) async {
224 return (await selectList(
225 "typeRaw = ? AND isPow = ? AND isDefault = ?", [serializeToInt(type), 1, 1]))
226 .firstOrNull;
227 }
228
229 static Future<List<Node>> getAllForWalletTypePow(WalletType type) async {
230 return selectList("typeRaw = ? AND isPow = ?", [serializeToInt(type), 1]);
231 }
232
233 static Future<List<Node>> getAllPow() async {
234 return selectList("isPow = ?", [1]);
235 }
236
237 static Future<Node?> get(int id) async {
238 return select("${selfIdColumn} = ?", [id]);
239 }
240
241 int id;
242 late String uriRaw;
243 String? login;
244 String? password;
245 late int typeRaw;
246 bool? useSSL;
247 bool trusted;
248 bool isPow;
249 String? socksProxyAddress;
250 String? path;
251 bool? isElectrs;
252 bool? supportsSilentPayments;
253 bool? supportsMweb;
254 bool isEnabledForAutoSwitching;
255 bool isOfficial;
256 bool isBuiltin;
257 bool isDefault;
258
259 String? label;
260
261 static String get tableName => "Node";
262 static String get selfIdColumn => "${tableName}Id";
263
264 bool get isSSL => useSSL ?? false;
265
266 bool get useSocksProxy => socksProxyAddress == null ? false : socksProxyAddress!.isNotEmpty;
267
268 Uri get uri {
269 try {
270 return _uri;
271 } catch (e) {
272 printV(e);
273 return Uri();
274 }
275 }
276
277 Uri get _uri {
278 switch (type) {
279 case WalletType.monero:
280 case WalletType.zcash:
281 case WalletType.haven:
282 case WalletType.wownero:
283 return Uri.http(uriRaw, '');
284 case WalletType.bitcoin:
285 case WalletType.litecoin:
286 case WalletType.bitcoinCash:
287 case WalletType.dogecoin:
288 return createUriFromElectrumAddress(uriRaw, path!);
289 case WalletType.nano:
290 case WalletType.banano:
291 case WalletType.ethereum:
292 case WalletType.polygon:
293 case WalletType.base:
294 case WalletType.bsc:
295 case WalletType.arbitrum:
296 case WalletType.solana:
297 case WalletType.tron:
298 case WalletType.zano:
299 case WalletType.decred:
300 return Uri.parse(
301 "http${isSSL ? "s" : ""}://$uriRaw${path!.startsWith("/") || path!.isEmpty ? path : "/$path"}");
302 case WalletType.none:
303 throw Exception('Unexpected type ${type.toString()} for Node uri');
304 }
305 }
306
307 bool get isValidProxyAddress => socksProxyAddress?.contains(':') ?? false;
308
309 @override
310 bool operator ==(other) =>
311 other is Node &&
312 (other.uriRaw == uriRaw &&
313 other.login == login &&
314 other.label == label &&
315 other.password == password &&
316 other.typeRaw == typeRaw &&
317 other.useSSL == useSSL &&
318 other.trusted == trusted &&
319 other.socksProxyAddress == socksProxyAddress &&
320 other.path == path);
321
322 @override
323 int get hashCode =>
324 uriRaw.hashCode ^
325 login.hashCode ^
326 label.hashCode ^
327 password.hashCode ^
328 typeRaw.hashCode ^
329 useSSL.hashCode ^
330 trusted.hashCode ^
331 socksProxyAddress.hashCode ^
332 path.hashCode;
333
334 WalletType get type => deserializeFromInt(typeRaw);
335
336 set type(WalletType type) => typeRaw = serializeToInt(type);
337
338 Future<bool> requestNode() async {
339 try {
340 switch (type) {
341 case WalletType.monero:
342 case WalletType.haven:
343 case WalletType.wownero:
344 return requestMoneroNode();
345 case WalletType.nano:
346 case WalletType.banano:
347 return requestNanoNode();
348 case WalletType.bitcoin:
349 case WalletType.litecoin:
350 case WalletType.bitcoinCash:
351 case WalletType.ethereum:
352 case WalletType.polygon:
353 case WalletType.base:
354 case WalletType.arbitrum:
355 case WalletType.bsc:
356 case WalletType.solana:
357 case WalletType.tron:
358 case WalletType.dogecoin:
359 case WalletType.zcash:
360 return requestElectrumServer();
361 case WalletType.zano:
362 return requestZanoNode();
363 case WalletType.decred:
364 return requestDecredNode();
365 case WalletType.none:
366 return false;
367 }
368 } catch (_) {
369 return false;
370 }
371 }
372
373 Future<bool> requestZanoNode() async {
374 final path = '/json_rpc';
375 final rpcUri = isSSL ? Uri.https(uri.authority, path) : Uri.http(uri.authority, path);
376 final body = {'jsonrpc': '2.0', 'id': '0', 'method': "getinfo"};
377
378 try {
379 final jsonBody = json.encode(body);
380
381 final response = await ProxyWrapper().post(
382 clearnetUri: rpcUri,
383 headers: {'Content-Type': 'application/json'},
384 body: jsonBody,
385 );
386
387 final resBody = json.decode(response.body) as Map<String, dynamic>;
388
389 return resBody['result']['height'] != null;
390 } catch (e) {
391 printV("error: $e");
392 return false;
393 }
394 }
395
396 Future<bool> requestMoneroNode({String methodName = 'get_info'}) async {
397 if (useSocksProxy) {
398 return await requestNodeWithProxy();
399 }
400
401 final path = '/json_rpc';
402 final rpcUri = isSSL ? Uri.https(uri.authority, path) : Uri.http(uri.authority, path);
403 final body = {'jsonrpc': '2.0', 'id': '0', 'method': methodName};
404
405 try {
406 final jsonBody = json.encode(body);
407
408 final response = await ProxyWrapper().post(
409 clearnetUri: rpcUri,
410 headers: {'Content-Type': 'application/json'},
411 body: jsonBody,
412 allowMitmMoneroBypassSSLCheck: true);
413 // Check if we received a 401 Unauthorized response
414 if (response.statusCode == 401) {
415 final daemonRpc = DaemonRpc(
416 rpcUri.toString(),
417 username: login ?? '',
418 password: password ?? '',
419 );
420 final response = await daemonRpc.call('get_info', {});
421 return !(response['offline'] as bool);
422 }
423
424 final responseString = await response.body;
425
426 if ((responseString.contains("400 Bad Request") // Some other generic error
427 ||
428 responseString.contains("plain HTTP request was sent to HTTPS port") // Cloudflare
429 ||
430 response.headers["location"] != null // Generic reverse proxy
431 ||
432 responseString
433 .contains("301 Moved Permanently") // Poorly configured generic reverse proxy
434 ) &&
435 !(useSSL ?? false)) {
436 final oldUseSSL = useSSL;
437 useSSL = true;
438 try {
439 final ret = await requestMoneroNode(methodName: methodName);
440 if (ret == true) {
441 await save();
442 return ret;
443 }
444 useSSL = oldUseSSL;
445 } catch (e) {
446 useSSL = oldUseSSL;
447 }
448 }
449
450 final resBody = json.decode(response.body) as Map<String, dynamic>;
451 return !(resBody['result']['offline'] as bool);
452 } catch (e) {
453 printV("error: $e");
454 return false;
455 }
456 }
457
458 Future<bool> requestNodeWithProxy() async {
459 if (!isValidProxyAddress && !CakeTor.instance!.enabled) {
460 return false;
461 }
462
463 String? proxy = socksProxyAddress;
464
465 if ((proxy?.isEmpty ?? true) && CakeTor.instance!.enabled) {
466 proxy = "${InternetAddress.loopbackIPv4.address}:${CakeTor.instance!.port}";
467 }
468 printV("proxy: $proxy");
469 if (proxy == null) {
470 return false;
471 }
472 final proxyAddress = proxy.split(':')[0];
473 final proxyPort = int.parse(proxy.split(':')[1]);
474 try {
475 final socket = await Socket.connect(proxyAddress, proxyPort, timeout: Duration(seconds: 5));
476 socket.destroy();
477 return true;
478 } catch (_) {
479 return false;
480 }
481 }
482
483 // TODO: this will return true most of the time, even if the node has useSSL set to true while
484 // it doesn't support SSL or vice versa, because it will connect normally, but it will fail if
485 // you try to communicate with it
486 Future<bool> requestElectrumServer() async {
487 try {
488 final ProxySocket socket;
489 socket = await ProxyWrapper().getSocksSocket(useSSL ?? false, uri.host, uri.port);
490
491 socket.destroy();
492 return true;
493 } catch (_) {
494 return false;
495 }
496 }
497
498 Future<bool> requestNanoNode() async {
499 try {
500 final response = await ProxyWrapper().post(
501 clearnetUri: uri,
502 headers: {"Content-Type": "application/json", "nano-app": "cake-wallet"},
503 body: jsonEncode(
504 {
505 "action": "account_balance",
506 "account": "nano_38713x95zyjsqzx6nm1dsom1jmm668owkeb9913ax6nfgj15az3nu8xkx579",
507 },
508 ),
509 );
510
511 final data = jsonDecode(response.body);
512 if (response.statusCode != 200 ||
513 data["error"] != null ||
514 data["balance"] == null ||
515 data["receivable"] == null) {
516 throw Exception(
517 "Error while trying to get balance! ${data["error"] != null ? data["error"] : ""}");
518 }
519 return true;
520 } catch (_) {
521 return false;
522 }
523 }
524
525 Future<bool> requestEthereumServer() async {
526 try {
527 final req = await ProxyWrapper()
528 .getHttpClient()
529 .getUrl(
530 uri,
531 )
532 .timeout(Duration(seconds: 15));
533 final response = await req.close();
534
535 return response.statusCode >= 200 && response.statusCode < 300;
536 } catch (err) {
537 printV("Failed to request ethereum server: $err");
538 return false;
539 }
540 }
541
542 Future<bool> requestDecredNode() async {
543 if (uri.host == "default-spv-nodes") {
544 // Just show default port as ok. The wallet will connect to a list of known
545 // nodes automatically.
546 return true;
547 }
548 try {
549 final socket = await Socket.connect(uri.host, uri.port, timeout: Duration(seconds: 5));
550 socket.destroy();
551 return true;
552 } catch (_) {
553 return false;
554 }
555 }
556 }
557
558 /// https://github.com/ManyMath/digest_auth/
559 /// HTTP Digest authentication.
560 ///
561 /// Adapted from https://github.com/dart-lang/http/issues/605#issue-963962341.
562 ///
563 /// Created because http_auth was not working for Monero daemon RPC responses.
564 class DigestAuth {
565 final String username;
566 final String password;
567 String? realm;
568 String? nonce;
569 String? uri;
570 String? qop = "auth";
571 int _nonceCount = 0;
572
573 DigestAuth(this.username, this.password);
574
575 /// Initialize Digest parameters from the `WWW-Authenticate` header.
576 void initFromAuthorizationHeader(String authInfo) {
577 final Map<String, String>? values = _splitAuthenticateHeader(authInfo);
578 if (values != null) {
579 realm = values['realm'];
580 // Check if the nonce has changed.
581 if (nonce != values['nonce']) {
582 nonce = values['nonce'];
583 _nonceCount = 0; // Reset nonce count when nonce changes.
584 }
585 }
586 }
587
588 /// Generate the Digest Authorization header.
589 String getAuthString(String method, String uri) {
590 this.uri = uri;
591 _nonceCount++;
592 String cnonce = _computeCnonce();
593 String nc = _formatNonceCount(_nonceCount);
594
595 String ha1 = md5Hash("$username:$realm:$password");
596 String ha2 = md5Hash("$method:$uri");
597 String response = md5Hash("$ha1:$nonce:$nc:$cnonce:$qop:$ha2");
598
599 return 'Digest username="$username", realm="$realm", nonce="$nonce", uri="$uri", qop=$qop, nc=$nc, cnonce="$cnonce", response="$response"';
600 }
601
602 /// Helper to parse the `WWW-Authenticate` header.
603 Map<String, String>? _splitAuthenticateHeader(String? header) {
604 if (header == null || !header.startsWith('Digest ')) {
605 return null;
606 }
607 String token = header.substring(7); // Remove 'Digest '.
608 final Map<String, String> result = {};
609
610 final components = token.split(',').map((token) => token.trim());
611 for (final component in components) {
612 final kv = component.split('=');
613 final key = kv[0];
614 final value = kv.sublist(1).join('=').replaceAll('"', '');
615 result[key] = value;
616 }
617 return result;
618 }
619
620 /// Helper to compute a random cnonce.
621 String _computeCnonce() {
622 final math.Random rnd = math.Random();
623 final List<int> values = List<int>.generate(16, (i) => rnd.nextInt(256));
624 return hex.encode(values);
625 }
626
627 /// Helper to format the nonce count.
628 String _formatNonceCount(int count) => count.toRadixString(16).padLeft(8, '0');
629
630 /// Compute the MD5 hash of a string.
631 String md5Hash(String input) {
632 return md5.convert(utf8.encode(input)).toString();
633 }
634 }
635
636 class DaemonRpc {
637 final String rpcUrl;
638 final String username;
639 final String password;
640
641 DaemonRpc(this.rpcUrl, {required this.username, required this.password});
642
643 /// Perform a JSON-RPC call with Digest Authentication.
644 Future<Map<String, dynamic>> call(String method, Map<String, dynamic> params) async {
645 final client = ProxyWrapper().getHttpIOClient();
646 final DigestAuth digestAuth = DigestAuth(username, password);
647
648 // Initial request to get the `WWW-Authenticate` header.
649 final initialResponse = await client.post(
650 Uri.parse(rpcUrl),
651 headers: {
652 'Content-Type': 'application/json',
653 },
654 body: jsonEncode({
655 'jsonrpc': '2.0',
656 'id': '0',
657 'method': method,
658 'params': params,
659 }),
660 );
661
662 if (initialResponse.statusCode != 401 ||
663 !initialResponse.headers.containsKey('www-authenticate')) {
664 throw Exception('Unexpected response: ${initialResponse.body}');
665 }
666
667 // Extract Digest details from `WWW-Authenticate` header.
668 final String authInfo = initialResponse.headers['www-authenticate']!;
669 digestAuth.initFromAuthorizationHeader(authInfo);
670
671 // Create Authorization header for the second request.
672 String uri = Uri.parse(rpcUrl).path;
673 String authHeader = digestAuth.getAuthString('POST', uri);
674
675 // Make the authenticated request.
676 final authenticatedResponse = await client.post(
677 Uri.parse(rpcUrl),
678 headers: {
679 'Content-Type': 'application/json',
680 'Authorization': authHeader,
681 },
682 body: jsonEncode({
683 'jsonrpc': '2.0',
684 'id': '0',
685 'method': method,
686 'params': params,
687 }),
688 );
689
690 if (authenticatedResponse.statusCode != 200) {
691 throw Exception('RPC call failed: ${authenticatedResponse.body}');
692 }
693
694 final Map<String, dynamic> result =
695 jsonDecode(authenticatedResponse.body) as Map<String, dynamic>;
696 if (result['error'] != null) {
697 throw Exception('RPC Error: ${result['error']}');
698 }
699
700 return result['result'] as Map<String, dynamic>;
701 }
702 }