mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-28 06:28:41 +01:00
Upstream reformatted its whole tree in 16ddc0837. Matching it removes
formatting from the conflict surface of this and every later sync.
497 lines
14 KiB
Dart
497 lines
14 KiB
Dart
import 'dart:io';
|
|
import 'package:cw_core/keyable.dart';
|
|
import 'package:cw_core/utils/proxy_socket/abstract.dart';
|
|
import 'package:cw_core/utils/proxy_wrapper.dart';
|
|
import 'package:cw_core/utils/print_verbose.dart';
|
|
import 'dart:convert';
|
|
import 'package:hive/hive.dart';
|
|
import 'package:cw_core/hive_type_ids.dart';
|
|
import 'package:cw_core/wallet_type.dart';
|
|
import 'dart:math' as math;
|
|
import 'package:convert/convert.dart';
|
|
|
|
import 'package:crypto/crypto.dart';
|
|
|
|
part 'node.g.dart';
|
|
|
|
Uri createUriFromElectrumAddress(String address, String path) =>
|
|
Uri.tryParse('tcp://$address$path')!;
|
|
|
|
@HiveType(typeId: Node.typeId)
|
|
class Node extends HiveObject with Keyable {
|
|
Node({
|
|
this.label,
|
|
this.login,
|
|
this.password,
|
|
this.useSSL,
|
|
this.trusted = false,
|
|
this.socksProxyAddress,
|
|
this.path = '',
|
|
this.isEnabledForAutoSwitching = false,
|
|
String? uri,
|
|
WalletType? type,
|
|
}) {
|
|
if (uri != null) {
|
|
uriRaw = uri;
|
|
}
|
|
if (type != null) {
|
|
this.type = type;
|
|
}
|
|
}
|
|
|
|
@override
|
|
String toString() {
|
|
return """Node(
|
|
uriRaw: $uriRaw,
|
|
path: $path,
|
|
login: $login,
|
|
password: $password,
|
|
useSSL: $useSSL,
|
|
trusted: $trusted,
|
|
socksProxyAddress: $socksProxyAddress,
|
|
isEnabledForAutoSwitching: $isEnabledForAutoSwitching,
|
|
})""";
|
|
}
|
|
|
|
Node.fromMap(Map<String, Object?> map)
|
|
: uriRaw = map['uri'] as String? ?? '',
|
|
path = map['path'] as String? ?? '',
|
|
login = map['login'] as String?,
|
|
label = map['label'] as String?,
|
|
password = map['password'] as String?,
|
|
useSSL = map['useSSL'] as bool?,
|
|
trusted = map['trusted'] as bool? ?? false,
|
|
socksProxyAddress = map['socksProxyPort'] as String?,
|
|
isEnabledForAutoSwitching = map['isEnabledForAutoSwitching'] as bool? ?? false;
|
|
|
|
static const typeId = NODE_TYPE_ID;
|
|
static const boxName = 'Nodes';
|
|
|
|
@HiveField(0, defaultValue: '')
|
|
late String uriRaw;
|
|
|
|
@HiveField(1)
|
|
String? login;
|
|
|
|
@HiveField(2)
|
|
String? password;
|
|
|
|
@HiveField(3, defaultValue: 0)
|
|
late int typeRaw;
|
|
|
|
@HiveField(4)
|
|
bool? useSSL;
|
|
|
|
@HiveField(5, defaultValue: false)
|
|
bool trusted;
|
|
|
|
@HiveField(6)
|
|
String? socksProxyAddress;
|
|
|
|
@HiveField(7, defaultValue: '')
|
|
String? path;
|
|
|
|
@HiveField(8)
|
|
bool? isElectrs;
|
|
|
|
@HiveField(9)
|
|
bool? supportsSilentPayments;
|
|
|
|
@HiveField(10)
|
|
bool? supportsMweb;
|
|
|
|
@HiveField(11, defaultValue: false)
|
|
bool isEnabledForAutoSwitching;
|
|
|
|
@HiveField(12, defaultValue: '')
|
|
String? label;
|
|
|
|
bool get isSSL => useSSL ?? false;
|
|
|
|
bool get useSocksProxy => socksProxyAddress == null ? false : socksProxyAddress!.isNotEmpty;
|
|
|
|
Uri get uri {
|
|
switch (type) {
|
|
case WalletType.monero:
|
|
case WalletType.wownero:
|
|
return Uri.http(uriRaw, '');
|
|
case WalletType.bitcoin:
|
|
case WalletType.litecoin:
|
|
case WalletType.bitcoinCash:
|
|
case WalletType.dogecoin:
|
|
return createUriFromElectrumAddress(uriRaw, path!);
|
|
case WalletType.nano:
|
|
case WalletType.banano:
|
|
case WalletType.ethereum:
|
|
case WalletType.polygon:
|
|
case WalletType.base:
|
|
case WalletType.bsc:
|
|
case WalletType.arbitrum:
|
|
return Uri.parse(
|
|
"http${isSSL ? "s" : ""}://$uriRaw${path!.startsWith("/") || path!.isEmpty ? path : "/$path"}");
|
|
case WalletType.none:
|
|
throw Exception('Unexpected type ${type.toString()} for Node uri');
|
|
}
|
|
}
|
|
|
|
bool get isValidProxyAddress => socksProxyAddress?.contains(':') ?? false;
|
|
|
|
@override
|
|
bool operator ==(other) =>
|
|
other is Node &&
|
|
(other.uriRaw == uriRaw &&
|
|
other.login == login &&
|
|
other.label == label &&
|
|
other.password == password &&
|
|
other.typeRaw == typeRaw &&
|
|
other.useSSL == useSSL &&
|
|
other.trusted == trusted &&
|
|
other.socksProxyAddress == socksProxyAddress &&
|
|
other.path == path);
|
|
|
|
@override
|
|
int get hashCode =>
|
|
uriRaw.hashCode ^
|
|
login.hashCode ^
|
|
label.hashCode ^
|
|
password.hashCode ^
|
|
typeRaw.hashCode ^
|
|
useSSL.hashCode ^
|
|
trusted.hashCode ^
|
|
socksProxyAddress.hashCode ^
|
|
path.hashCode;
|
|
|
|
@override
|
|
dynamic get keyIndex {
|
|
_keyIndex ??= key;
|
|
return _keyIndex;
|
|
}
|
|
|
|
WalletType get type => deserializeFromInt(typeRaw);
|
|
|
|
set type(WalletType type) => typeRaw = serializeToInt(type);
|
|
|
|
dynamic _keyIndex;
|
|
|
|
Future<bool> requestNode() async {
|
|
try {
|
|
switch (type) {
|
|
case WalletType.monero:
|
|
case WalletType.wownero:
|
|
return requestMoneroNode();
|
|
case WalletType.nano:
|
|
case WalletType.banano:
|
|
return requestNanoNode();
|
|
case WalletType.bitcoin:
|
|
case WalletType.litecoin:
|
|
case WalletType.bitcoinCash:
|
|
case WalletType.ethereum:
|
|
case WalletType.polygon:
|
|
case WalletType.base:
|
|
case WalletType.arbitrum:
|
|
case WalletType.bsc:
|
|
case WalletType.dogecoin:
|
|
return requestElectrumServer();
|
|
case WalletType.none:
|
|
return false;
|
|
}
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<bool> requestMoneroNode({String methodName = 'get_info'}) async {
|
|
if (useSocksProxy) {
|
|
return await requestNodeWithProxy();
|
|
}
|
|
|
|
final path = '/json_rpc';
|
|
final rpcUri = isSSL ? Uri.https(uri.authority, path) : Uri.http(uri.authority, path);
|
|
final body = {'jsonrpc': '2.0', 'id': '0', 'method': methodName};
|
|
|
|
try {
|
|
final client = ProxyWrapper().getHttpIOClient();
|
|
|
|
final jsonBody = json.encode(body);
|
|
|
|
final response = await client.post(
|
|
rpcUri,
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: jsonBody,
|
|
);
|
|
// Check if we received a 401 Unauthorized response
|
|
if (response.statusCode == 401) {
|
|
final daemonRpc = DaemonRpc(
|
|
rpcUri.toString(),
|
|
username: login ?? '',
|
|
password: password ?? '',
|
|
);
|
|
final response = await daemonRpc.call('get_info', {});
|
|
return !(response['offline'] as bool);
|
|
}
|
|
|
|
final responseString = await response.body;
|
|
|
|
if ((responseString.contains("400 Bad Request") // Some other generic error
|
|
||
|
|
responseString.contains("plain HTTP request was sent to HTTPS port") // Cloudflare
|
|
||
|
|
response.headers["location"] != null // Generic reverse proxy
|
|
||
|
|
responseString
|
|
.contains("301 Moved Permanently") // Poorly configured generic reverse proxy
|
|
) &&
|
|
!(useSSL ?? false)) {
|
|
final oldUseSSL = useSSL;
|
|
useSSL = true;
|
|
try {
|
|
final ret = await requestMoneroNode(methodName: methodName);
|
|
if (ret == true) {
|
|
await save();
|
|
return ret;
|
|
}
|
|
useSSL = oldUseSSL;
|
|
} catch (e) {
|
|
useSSL = oldUseSSL;
|
|
}
|
|
}
|
|
|
|
final resBody = json.decode(response.body) as Map<String, dynamic>;
|
|
return !(resBody['result']['offline'] as bool);
|
|
} catch (e) {
|
|
printV("error: $e");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<bool> requestNodeWithProxy() async {
|
|
if (!isValidProxyAddress && !CakeTor.instance!.enabled) {
|
|
return false;
|
|
}
|
|
|
|
String? proxy = socksProxyAddress;
|
|
|
|
if ((proxy?.isEmpty ?? true) && CakeTor.instance!.enabled) {
|
|
proxy = "${InternetAddress.loopbackIPv4.address}:${CakeTor.instance!.port}";
|
|
}
|
|
printV("proxy: $proxy");
|
|
if (proxy == null) {
|
|
return false;
|
|
}
|
|
final proxyAddress = proxy.split(':')[0];
|
|
final proxyPort = int.parse(proxy.split(':')[1]);
|
|
try {
|
|
final socket = await Socket.connect(proxyAddress, proxyPort, timeout: Duration(seconds: 5));
|
|
socket.destroy();
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// TODO: this will return true most of the time, even if the node has useSSL set to true while
|
|
// it doesn't support SSL or vice versa, because it will connect normally, but it will fail if
|
|
// you try to communicate with it
|
|
Future<bool> requestElectrumServer() async {
|
|
try {
|
|
final ProxySocket socket;
|
|
socket = await ProxyWrapper().getSocksSocket(useSSL ?? false, uri.host, uri.port);
|
|
|
|
socket.destroy();
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<bool> requestNanoNode() async {
|
|
try {
|
|
final response = await ProxyWrapper().post(
|
|
clearnetUri: uri,
|
|
headers: {"Content-Type": "application/json", "nano-app": "cake-wallet"},
|
|
body: jsonEncode(
|
|
{
|
|
"action": "account_balance",
|
|
"account": "nano_38713x95zyjsqzx6nm1dsom1jmm668owkeb9913ax6nfgj15az3nu8xkx579",
|
|
},
|
|
),
|
|
);
|
|
|
|
final data = jsonDecode(response.body);
|
|
if (response.statusCode != 200 ||
|
|
data["error"] != null ||
|
|
data["balance"] == null ||
|
|
data["receivable"] == null) {
|
|
throw Exception(
|
|
"Error while trying to get balance! ${data["error"] != null ? data["error"] : ""}");
|
|
}
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<bool> requestEthereumServer() async {
|
|
try {
|
|
final req = await ProxyWrapper()
|
|
.getHttpClient()
|
|
.getUrl(
|
|
uri,
|
|
)
|
|
.timeout(Duration(seconds: 15));
|
|
final response = await req.close();
|
|
|
|
return response.statusCode >= 200 && response.statusCode < 300;
|
|
} catch (err) {
|
|
printV("Failed to request ethereum server: $err");
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// https://github.com/ManyMath/digest_auth/
|
|
/// HTTP Digest authentication.
|
|
///
|
|
/// Adapted from https://github.com/dart-lang/http/issues/605#issue-963962341.
|
|
///
|
|
/// Created because http_auth was not working for Monero daemon RPC responses.
|
|
class DigestAuth {
|
|
final String username;
|
|
final String password;
|
|
String? realm;
|
|
String? nonce;
|
|
String? uri;
|
|
String? qop = "auth";
|
|
int _nonceCount = 0;
|
|
|
|
DigestAuth(this.username, this.password);
|
|
|
|
/// Initialize Digest parameters from the `WWW-Authenticate` header.
|
|
void initFromAuthorizationHeader(String authInfo) {
|
|
final Map<String, String>? values = _splitAuthenticateHeader(authInfo);
|
|
if (values != null) {
|
|
realm = values['realm'];
|
|
// Check if the nonce has changed.
|
|
if (nonce != values['nonce']) {
|
|
nonce = values['nonce'];
|
|
_nonceCount = 0; // Reset nonce count when nonce changes.
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Generate the Digest Authorization header.
|
|
String getAuthString(String method, String uri) {
|
|
this.uri = uri;
|
|
_nonceCount++;
|
|
String cnonce = _computeCnonce();
|
|
String nc = _formatNonceCount(_nonceCount);
|
|
|
|
String ha1 = md5Hash("$username:$realm:$password");
|
|
String ha2 = md5Hash("$method:$uri");
|
|
String response = md5Hash("$ha1:$nonce:$nc:$cnonce:$qop:$ha2");
|
|
|
|
return 'Digest username="$username", realm="$realm", nonce="$nonce", uri="$uri", qop=$qop, nc=$nc, cnonce="$cnonce", response="$response"';
|
|
}
|
|
|
|
/// Helper to parse the `WWW-Authenticate` header.
|
|
Map<String, String>? _splitAuthenticateHeader(String? header) {
|
|
if (header == null || !header.startsWith('Digest ')) {
|
|
return null;
|
|
}
|
|
String token = header.substring(7); // Remove 'Digest '.
|
|
final Map<String, String> result = {};
|
|
|
|
final components = token.split(',').map((token) => token.trim());
|
|
for (final component in components) {
|
|
final kv = component.split('=');
|
|
final key = kv[0];
|
|
final value = kv.sublist(1).join('=').replaceAll('"', '');
|
|
result[key] = value;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// Helper to compute a random cnonce.
|
|
String _computeCnonce() {
|
|
final math.Random rnd = math.Random();
|
|
final List<int> values = List<int>.generate(16, (i) => rnd.nextInt(256));
|
|
return hex.encode(values);
|
|
}
|
|
|
|
/// Helper to format the nonce count.
|
|
String _formatNonceCount(int count) => count.toRadixString(16).padLeft(8, '0');
|
|
|
|
/// Compute the MD5 hash of a string.
|
|
String md5Hash(String input) {
|
|
return md5.convert(utf8.encode(input)).toString();
|
|
}
|
|
}
|
|
|
|
class DaemonRpc {
|
|
final String rpcUrl;
|
|
final String username;
|
|
final String password;
|
|
|
|
DaemonRpc(this.rpcUrl, {required this.username, required this.password});
|
|
|
|
/// Perform a JSON-RPC call with Digest Authentication.
|
|
Future<Map<String, dynamic>> call(String method, Map<String, dynamic> params) async {
|
|
final client = ProxyWrapper().getHttpIOClient();
|
|
final DigestAuth digestAuth = DigestAuth(username, password);
|
|
|
|
// Initial request to get the `WWW-Authenticate` header.
|
|
final initialResponse = await client.post(
|
|
Uri.parse(rpcUrl),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: jsonEncode({
|
|
'jsonrpc': '2.0',
|
|
'id': '0',
|
|
'method': method,
|
|
'params': params,
|
|
}),
|
|
);
|
|
|
|
if (initialResponse.statusCode != 401 ||
|
|
!initialResponse.headers.containsKey('www-authenticate')) {
|
|
throw Exception('Unexpected response: ${initialResponse.body}');
|
|
}
|
|
|
|
// Extract Digest details from `WWW-Authenticate` header.
|
|
final String authInfo = initialResponse.headers['www-authenticate']!;
|
|
digestAuth.initFromAuthorizationHeader(authInfo);
|
|
|
|
// Create Authorization header for the second request.
|
|
String uri = Uri.parse(rpcUrl).path;
|
|
String authHeader = digestAuth.getAuthString('POST', uri);
|
|
|
|
// Make the authenticated request.
|
|
final authenticatedResponse = await client.post(
|
|
Uri.parse(rpcUrl),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': authHeader,
|
|
},
|
|
body: jsonEncode({
|
|
'jsonrpc': '2.0',
|
|
'id': '0',
|
|
'method': method,
|
|
'params': params,
|
|
}),
|
|
);
|
|
|
|
if (authenticatedResponse.statusCode != 200) {
|
|
throw Exception('RPC call failed: ${authenticatedResponse.body}');
|
|
}
|
|
|
|
final Map<String, dynamic> result =
|
|
jsonDecode(authenticatedResponse.body) as Map<String, dynamic>;
|
|
if (result['error'] != null) {
|
|
throw Exception('RPC Error: ${result['error']}');
|
|
}
|
|
|
|
return result['result'] as Map<String, dynamic>;
|
|
}
|
|
}
|