Skip to content

Commit 3611893

Browse files
authored
Merge branch 'main' into keystone-integration
2 parents d1729ce + aef71d1 commit 3611893

File tree

3 files changed

+178
-38
lines changed

3 files changed

+178
-38
lines changed

cw_core/lib/node.dart

Lines changed: 174 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import 'dart:io';
2+
import 'dart:math';
23
import 'package:cw_core/keyable.dart';
4+
import 'package:cw_core/utils/print_verbose.dart';
35
import 'dart:convert';
46
import 'package:http/http.dart' as http;
57
import 'package:hive/hive.dart';
68
import 'package:cw_core/hive_type_ids.dart';
79
import 'package:cw_core/wallet_type.dart';
810
import 'package:http/io_client.dart' as ioc;
11+
import 'dart:math' as math;
12+
import 'package:convert/convert.dart';
13+
import 'package:crypto/crypto.dart' as crypto;
914

10-
// import 'package:tor/tor.dart';
15+
import 'package:crypto/crypto.dart';
1116

1217
part 'node.g.dart';
1318

@@ -170,34 +175,43 @@ class Node extends HiveObject with Keyable {
170175
}
171176

172177
Future<bool> requestMoneroNode() async {
173-
if (uri.toString().contains(".onion") || useSocksProxy) {
178+
if (useSocksProxy) {
174179
return await requestNodeWithProxy();
175180
}
181+
182+
176183
final path = '/json_rpc';
177184
final rpcUri = isSSL ? Uri.https(uri.authority, path) : Uri.http(uri.authority, path);
178-
final realm = 'monero-rpc';
179185
final body = {'jsonrpc': '2.0', 'id': '0', 'method': 'get_info'};
180186

187+
181188
try {
182189
final authenticatingClient = HttpClient();
183-
184190
authenticatingClient.badCertificateCallback =
185191
((X509Certificate cert, String host, int port) => true);
186192

187-
authenticatingClient.addCredentials(
188-
rpcUri,
189-
realm,
190-
HttpClientDigestCredentials(login ?? '', password ?? ''),
191-
);
192193

193194
final http.Client client = ioc.IOClient(authenticatingClient);
194195

196+
final jsonBody = json.encode(body);
197+
195198
final response = await client.post(
196199
rpcUri,
197200
headers: {'Content-Type': 'application/json'},
198-
body: json.encode(body),
201+
body: jsonBody,
199202
);
200-
client.close();
203+
// Check if we received a 401 Unauthorized response
204+
if (response.statusCode == 401) {
205+
final daemonRpc = DaemonRpc(
206+
rpcUri.toString(),
207+
username: login??'',
208+
password: password??'',
209+
);
210+
final response = await daemonRpc.call('get_info', {});
211+
return !(response['offline'] as bool);
212+
}
213+
214+
printV("node check response: ${response.body}");
201215

202216
if ((response.body.contains("400 Bad Request") // Some other generic error
203217
||
@@ -225,7 +239,8 @@ class Node extends HiveObject with Keyable {
225239

226240
final resBody = json.decode(response.body) as Map<String, dynamic>;
227241
return !(resBody['result']['offline'] as bool);
228-
} catch (_) {
242+
} catch (e) {
243+
printV("error: $e");
229244
return false;
230245
}
231246
}
@@ -316,3 +331,150 @@ class Node extends HiveObject with Keyable {
316331
}
317332
}
318333
}
334+
335+
/// https://github.com/ManyMath/digest_auth/
336+
/// HTTP Digest authentication.
337+
///
338+
/// Adapted from https://github.com/dart-lang/http/issues/605#issue-963962341.
339+
///
340+
/// Created because http_auth was not working for Monero daemon RPC responses.
341+
class DigestAuth {
342+
final String username;
343+
final String password;
344+
String? realm;
345+
String? nonce;
346+
String? uri;
347+
String? qop = "auth";
348+
int _nonceCount = 0;
349+
350+
DigestAuth(this.username, this.password);
351+
352+
/// Initialize Digest parameters from the `WWW-Authenticate` header.
353+
void initFromAuthorizationHeader(String authInfo) {
354+
final Map<String, String>? values = _splitAuthenticateHeader(authInfo);
355+
if (values != null) {
356+
realm = values['realm'];
357+
// Check if the nonce has changed.
358+
if (nonce != values['nonce']) {
359+
nonce = values['nonce'];
360+
_nonceCount = 0; // Reset nonce count when nonce changes.
361+
}
362+
}
363+
}
364+
365+
/// Generate the Digest Authorization header.
366+
String getAuthString(String method, String uri) {
367+
this.uri = uri;
368+
_nonceCount++;
369+
String cnonce = _computeCnonce();
370+
String nc = _formatNonceCount(_nonceCount);
371+
372+
String ha1 = md5Hash("$username:$realm:$password");
373+
String ha2 = md5Hash("$method:$uri");
374+
String response = md5Hash("$ha1:$nonce:$nc:$cnonce:$qop:$ha2");
375+
376+
return 'Digest username="$username", realm="$realm", nonce="$nonce", uri="$uri", qop=$qop, nc=$nc, cnonce="$cnonce", response="$response"';
377+
}
378+
379+
/// Helper to parse the `WWW-Authenticate` header.
380+
Map<String, String>? _splitAuthenticateHeader(String? header) {
381+
if (header == null || !header.startsWith('Digest ')) {
382+
return null;
383+
}
384+
String token = header.substring(7); // Remove 'Digest '.
385+
final Map<String, String> result = {};
386+
387+
final components = token.split(',').map((token) => token.trim());
388+
for (final component in components) {
389+
final kv = component.split('=');
390+
final key = kv[0];
391+
final value = kv.sublist(1).join('=').replaceAll('"', '');
392+
result[key] = value;
393+
}
394+
return result;
395+
}
396+
397+
/// Helper to compute a random cnonce.
398+
String _computeCnonce() {
399+
final math.Random rnd = math.Random();
400+
final List<int> values = List<int>.generate(16, (i) => rnd.nextInt(256));
401+
return hex.encode(values);
402+
}
403+
404+
/// Helper to format the nonce count.
405+
String _formatNonceCount(int count) =>
406+
count.toRadixString(16).padLeft(8, '0');
407+
408+
/// Compute the MD5 hash of a string.
409+
String md5Hash(String input) {
410+
return md5.convert(utf8.encode(input)).toString();
411+
}
412+
}
413+
414+
class DaemonRpc {
415+
final String rpcUrl;
416+
final String username;
417+
final String password;
418+
419+
DaemonRpc(this.rpcUrl, {required this.username, required this.password});
420+
421+
/// Perform a JSON-RPC call with Digest Authentication.
422+
Future<Map<String, dynamic>> call(
423+
String method, Map<String, dynamic> params) async {
424+
final http.Client client = http.Client();
425+
final DigestAuth digestAuth = DigestAuth(username, password);
426+
427+
// Initial request to get the `WWW-Authenticate` header.
428+
final initialResponse = await client.post(
429+
Uri.parse(rpcUrl),
430+
headers: {
431+
'Content-Type': 'application/json',
432+
},
433+
body: jsonEncode({
434+
'jsonrpc': '2.0',
435+
'id': '0',
436+
'method': method,
437+
'params': params,
438+
}),
439+
);
440+
441+
if (initialResponse.statusCode != 401 ||
442+
!initialResponse.headers.containsKey('www-authenticate')) {
443+
throw Exception('Unexpected response: ${initialResponse.body}');
444+
}
445+
446+
// Extract Digest details from `WWW-Authenticate` header.
447+
final String authInfo = initialResponse.headers['www-authenticate']!;
448+
digestAuth.initFromAuthorizationHeader(authInfo);
449+
450+
// Create Authorization header for the second request.
451+
String uri = Uri.parse(rpcUrl).path;
452+
String authHeader = digestAuth.getAuthString('POST', uri);
453+
454+
// Make the authenticated request.
455+
final authenticatedResponse = await client.post(
456+
Uri.parse(rpcUrl),
457+
headers: {
458+
'Content-Type': 'application/json',
459+
'Authorization': authHeader,
460+
},
461+
body: jsonEncode({
462+
'jsonrpc': '2.0',
463+
'id': '0',
464+
'method': method,
465+
'params': params,
466+
}),
467+
);
468+
469+
if (authenticatedResponse.statusCode != 200) {
470+
throw Exception('RPC call failed: ${authenticatedResponse.body}');
471+
}
472+
473+
final Map<String, dynamic> result = jsonDecode(authenticatedResponse.body) as Map<String, dynamic>;
474+
if (result['error'] != null) {
475+
throw Exception('RPC Error: ${result['error']}');
476+
}
477+
478+
return result['result'] as Map<String, dynamic>;
479+
}
480+
}

cw_monero/lib/monero_transaction_info.dart

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,14 @@
11
import 'package:cw_core/transaction_info.dart';
22
import 'package:cw_core/monero_amount_format.dart';
3-
import 'package:cw_core/parseBoolFromString.dart';
43
import 'package:cw_core/transaction_direction.dart';
54
import 'package:cw_core/format_amount.dart';
6-
import 'package:cw_monero/api/transaction_history.dart';
75

86
class MoneroTransactionInfo extends TransactionInfo {
97
MoneroTransactionInfo(this.txHash, this.height, this.direction, this.date,
108
this.isPending, this.amount, this.accountIndex, this.addressIndex, this.fee,
119
this.confirmations) :
1210
id = "${txHash}_${amount}_${accountIndex}_${addressIndex}";
1311

14-
MoneroTransactionInfo.fromMap(Map<String, Object?> map)
15-
: id = "${map['hash']}_${map['amount']}_${map['accountIndex']}_${map['addressIndex']}",
16-
txHash = map['hash'] as String,
17-
height = (map['height'] ?? 0) as int,
18-
direction = map['direction'] != null
19-
? parseTransactionDirectionFromNumber(map['direction'] as String)
20-
: TransactionDirection.incoming,
21-
date = DateTime.fromMillisecondsSinceEpoch(
22-
(int.tryParse(map['timestamp'] as String? ?? '') ?? 0) * 1000),
23-
isPending = parseBoolFromString(map['isPending'] as String),
24-
amount = map['amount'] as int,
25-
accountIndex = int.parse(map['accountIndex'] as String),
26-
addressIndex = map['addressIndex'] as int,
27-
confirmations = map['confirmations'] as int,
28-
key = getTxKey((map['hash'] ?? '') as String),
29-
fee = map['fee'] as int? ?? 0 {
30-
additionalInfo = <String, dynamic>{
31-
'key': key,
32-
'accountIndex': accountIndex,
33-
'addressIndex': addressIndex
34-
};
35-
}
36-
3712
final String id;
3813
final String txHash;
3914
final int height;

lib/src/widgets/primary_button.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,10 @@ class LoadingPrimaryButton extends StatelessWidget {
9191
width: double.infinity,
9292
height: 52.0,
9393
child: TextButton(
94-
onPressed: (isLoading || isDisabled) ? null : onPressed,
94+
onPressed: (isLoading || isDisabled) ? null : () {
95+
FocusScope.of(context).unfocus();
96+
onPressed.call();
97+
},
9598
style: ButtonStyle(
9699
backgroundColor:
97100
MaterialStateProperty.all(isDisabled ? color.withOpacity(0.5) : color),

0 commit comments

Comments
 (0)