mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-17 05:41:29 +02:00
94 files changed, -5926 net lines. No stubs, no generators, no dead enum
values, no orphan switch branches. Removed for principled reasons (we
do not ship these chains) and to make the codebase smaller and friendlier
to contribute to.
What's gone:
- lib/{solana,tron,zano,decred,zcash,haven}/ directories
- lib/exchange/provider/jupiter_exchange_provider.dart (Solana-only)
- lib/entities/haven_seed_store.dart
- WalletType.{solana,tron,zano,decred,zcash,haven} enum values
(HiveField IDs are pinned: Wownero stays at 12, all surviving types
keep their IDs, so existing user-saved wallets deserialize unchanged)
- All ~70 lib/ files that imported the removed chain stubs or branched
on the removed WalletType values
- backupSeeds(Box<HavenSeedStore>) abstract method + Wownero implementation
- Jupiter exchange branches in send/exchange view models
- TronGrid usage settings, Zano alias lookups, Zcash transparent address
handling, Decred default-spv-nodes branches, Haven seed migration
What stays (intentional):
- CryptoCurrency.{xhv,sol,trx,dcr,zano,zec} enum entries in cw_core —
they're still used as exchange/swap currency identifiers in
address_validator.dart and elsewhere. The wallet types are gone but
the *currencies* are still tradeable through Trocador.
- cw_core/spl_token.dart and cw_core/tron_token.dart Hive adapters in
main.dart — the tokens still register as Hive types for backwards-
compat with any old box data.
- lib/buy/{moonpay,robinhood,onramper,dfx,kryptonim,meld}/ provider
source files (just stripped of solana/tron/etc references; providers
themselves remain dormant per provider_types.dart).
Pinned-ID note: WalletType enum now has gaps in @HiveField IDs (4, 10,
11, 13, 14, 18 removed). This is deliberate — re-using a freed ID later
would mis-deserialize old hive boxes. Don't renumber.
Bundled config / CI hasn't changed; configure.dart was already free of
these chains in commit 4eb7db0b7.
496 lines
14 KiB
Dart
496 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>;
|
|
}
|
|
}
|