mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-26 06:08:40 +01:00
Squashed migration of node Hive→SQLite, default-node handling, isPow/proxy support, arbitrum node updates, and related fixes. Replays node-sqlite-migration on top of current dev without the amount-refactor merge that the original branch carried. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
652 lines
19 KiB
Dart
652 lines
19 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:blockchain_utils/hex/hex.dart';
|
|
import 'package:cw_core/node_list.dart';
|
|
import 'package:cw_core/utils/print_verbose.dart';
|
|
import 'package:cw_core/utils/proxy_socket/abstract.dart';
|
|
import 'package:cw_core/utils/proxy_wrapper.dart';
|
|
import 'package:cw_core/wallet_type.dart';
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:sqflite/sqflite.dart';
|
|
|
|
import 'db/sqlite.dart';
|
|
|
|
Uri createUriFromElectrumAddress(String address, String path) =>
|
|
Uri.tryParse('tcp://$address$path')!;
|
|
|
|
Future<void> validateBuiltinNodes() async {
|
|
// ensures nodes stored as builtin in the db correspond to the nodes in the .yml files
|
|
|
|
final builtinFromDb = await Node.getAllBuiltin();
|
|
final builtinFromList = await loadAllDefaultNodes();
|
|
|
|
for (final listNode in builtinFromList) {
|
|
// preserve proxy settings from user
|
|
try {
|
|
final matchedDbNode = builtinFromDb.firstWhere(
|
|
(dbNode) => dbNode.uri == listNode.uri
|
|
);
|
|
listNode.socksProxyAddress = matchedDbNode.socksProxyAddress;
|
|
} catch(e) {}
|
|
}
|
|
|
|
final dbSet = builtinFromDb.toSet();
|
|
final listSet = builtinFromList.toSet();
|
|
|
|
final nodesToDelete = dbSet.difference(listSet);
|
|
final nodesToAdd = listSet.difference(dbSet);
|
|
|
|
for (final node in nodesToDelete) await node.delete();
|
|
for (final node in nodesToAdd) await node.save();
|
|
}
|
|
|
|
class Node {
|
|
Node({
|
|
this.id = 0,
|
|
this.label,
|
|
this.login,
|
|
this.password,
|
|
this.useSSL,
|
|
this.isPow = false,
|
|
this.trusted = false,
|
|
this.socksProxyAddress,
|
|
this.path = '',
|
|
this.isEnabledForAutoSwitching = false,
|
|
this.isOfficial = false,
|
|
this.isBuiltin = false,
|
|
this.isDefault = 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,
|
|
isOfficial: $isOfficial,
|
|
isBuiltin: $isBuiltin,
|
|
isDefault: $isDefault
|
|
})""";
|
|
}
|
|
|
|
Node.fromMap(Map<String, Object?> map)
|
|
: id = (map[selfIdColumn] ?? 0) as int,
|
|
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?,
|
|
isPow = (map["isPow"] != null && map['isPow'] != 0) as bool? ?? false,
|
|
useSSL = (map['useSSL'] != 0) as bool?,
|
|
typeRaw = (map["typeRaw"] ?? 0) as int,
|
|
trusted = (map['trusted'] != 0) as bool? ?? false,
|
|
socksProxyAddress = map['socksProxyAddress'] as String?,
|
|
isEnabledForAutoSwitching = (map['isEnabledForAutoSwitching'] != 0) as bool? ?? false,
|
|
isOfficial = (map['isOfficial'] != 0) as bool? ?? false,
|
|
isBuiltin = (map['isBuiltin'] != 0) as bool? ?? false,
|
|
isDefault = (map['isDefault'] != 0) as bool? ?? false;
|
|
|
|
Map<String, dynamic> toMap() {
|
|
return {
|
|
selfIdColumn: id,
|
|
'uri': uriRaw,
|
|
'path': path,
|
|
'login': login,
|
|
"label": label,
|
|
'password': password,
|
|
"isPow": isPow,
|
|
'useSSL': useSSL,
|
|
"typeRaw": typeRaw,
|
|
'trusted': trusted,
|
|
'socksProxyAddress': socksProxyAddress,
|
|
'isEnabledForAutoSwitching': isEnabledForAutoSwitching,
|
|
"isOfficial": isOfficial,
|
|
"isBuiltin": isBuiltin,
|
|
"isDefault": isDefault
|
|
};
|
|
}
|
|
|
|
Future<int> delete() async {
|
|
return await db!.delete(tableName, where: '${selfIdColumn} = ?', whereArgs: [id]);
|
|
}
|
|
|
|
static Future<int> deleteAll() async {
|
|
return await db!.delete(tableName, where: "isPow = ?", whereArgs: [false]);
|
|
}
|
|
|
|
static Future<int> deleteAllPow() async {
|
|
return await db!.delete(tableName, where: "isPow = ?", whereArgs: [true]);
|
|
}
|
|
|
|
Future<int> save() async {
|
|
|
|
final json = toMap();
|
|
if (json[selfIdColumn] == 0) {
|
|
json[selfIdColumn] = null;
|
|
}
|
|
id = await db!.insert(tableName, json, conflictAlgorithm: ConflictAlgorithm.replace);
|
|
return id;
|
|
}
|
|
|
|
|
|
static Future<List<Node>> selectList(String where, List<dynamic> whereArgs, {String? orderBy}) async {
|
|
if(orderBy == null) {
|
|
orderBy = selfIdColumn;
|
|
}
|
|
final list = await db!.query(
|
|
tableName,
|
|
where: where.isNotEmpty ? where : "1 = 1",
|
|
whereArgs: whereArgs.isNotEmpty ? whereArgs : null,
|
|
orderBy: orderBy,
|
|
);
|
|
printV("selectList: $list");
|
|
return List.generate(list.length, (index) => Node.fromMap(list[index]));
|
|
}
|
|
|
|
|
|
static Future<List<Node>> getAll() async {
|
|
return selectList("isPow = ?", [false]);
|
|
}
|
|
|
|
static Future<List<Node>> getAllBuiltin() async {
|
|
return selectList("isPow = ? AND isBuiltin = ?", [false, true]);
|
|
}
|
|
|
|
static Future<List<Node>> getAllPowBuiltin() async {
|
|
return selectList("isPow = ? AND isBuiltin = ?", [true, true]);
|
|
}
|
|
|
|
static Future<List<Node>> getAllForWalletType(WalletType type) async {
|
|
return selectList("typeRaw = ? AND isPow = ?", [serializeToInt(type), false]);
|
|
}
|
|
|
|
static Future<Node?> getDefaultForWalletType(WalletType type) async {
|
|
return (await selectList("typeRaw = ? AND isPow = ? AND isDefault = ?", [serializeToInt(type), false, true])).firstOrNull;
|
|
}
|
|
|
|
static Future<Node?> getDefaultPowForWalletType(WalletType type) async {
|
|
return (await selectList("typeRaw = ? AND isPow = ? AND isDefault = ?", [serializeToInt(type), true, true])).firstOrNull;
|
|
}
|
|
|
|
static Future<List<Node>> getAllForWalletTypePow(WalletType type) async {
|
|
return selectList("typeRaw = ? AND isPow = ?", [serializeToInt(type), true]);
|
|
}
|
|
|
|
static Future<List<Node>> getAllPow() async {
|
|
return selectList("isPow = ?", [true]);
|
|
}
|
|
|
|
|
|
int id;
|
|
late String uriRaw;
|
|
String? login;
|
|
String? password;
|
|
late int typeRaw;
|
|
bool? useSSL;
|
|
bool trusted;
|
|
bool isPow;
|
|
String? socksProxyAddress;
|
|
String? path;
|
|
bool? isElectrs;
|
|
bool? supportsSilentPayments;
|
|
bool? supportsMweb;
|
|
bool isEnabledForAutoSwitching;
|
|
bool isOfficial;
|
|
bool isBuiltin;
|
|
bool isDefault;
|
|
|
|
String? label;
|
|
|
|
static String get tableName => "Node";
|
|
static String get selfIdColumn => "${tableName}Id";
|
|
|
|
|
|
bool get isSSL => useSSL ?? false;
|
|
|
|
bool get useSocksProxy => socksProxyAddress == null ? false : socksProxyAddress!.isNotEmpty;
|
|
|
|
Uri get uri {
|
|
switch (type) {
|
|
case WalletType.monero:
|
|
case WalletType.zcash:
|
|
case WalletType.haven:
|
|
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:
|
|
case WalletType.solana:
|
|
case WalletType.tron:
|
|
case WalletType.zano:
|
|
case WalletType.decred:
|
|
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;
|
|
|
|
|
|
WalletType get type => deserializeFromInt(typeRaw);
|
|
|
|
set type(WalletType type) => typeRaw = serializeToInt(type);
|
|
|
|
Future<bool> requestNode() async {
|
|
try {
|
|
switch (type) {
|
|
case WalletType.monero:
|
|
case WalletType.haven:
|
|
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.solana:
|
|
case WalletType.tron:
|
|
case WalletType.dogecoin:
|
|
case WalletType.zcash:
|
|
return requestElectrumServer();
|
|
case WalletType.zano:
|
|
return requestZanoNode();
|
|
case WalletType.decred:
|
|
return requestDecredNode();
|
|
case WalletType.none:
|
|
return false;
|
|
}
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<bool> requestZanoNode() async {
|
|
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': "getinfo"};
|
|
|
|
try {
|
|
final jsonBody = json.encode(body);
|
|
|
|
final response = await ProxyWrapper().post(
|
|
clearnetUri: rpcUri,
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: jsonBody,
|
|
);
|
|
|
|
|
|
final resBody = json.decode(response.body) as Map<String, dynamic>;
|
|
|
|
return resBody['result']['height'] != null;
|
|
} catch (e) {
|
|
printV("error: $e");
|
|
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;
|
|
}
|
|
}
|
|
|
|
Future<bool> requestDecredNode() async {
|
|
if (uri.host == "default-spv-nodes") {
|
|
// Just show default port as ok. The wallet will connect to a list of known
|
|
// nodes automatically.
|
|
return true;
|
|
}
|
|
try {
|
|
final socket = await Socket.connect(uri.host, uri.port, timeout: Duration(seconds: 5));
|
|
socket.destroy();
|
|
return true;
|
|
} catch (_) {
|
|
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>;
|
|
}
|
|
}
|