diff --git a/cw_core/lib/node.dart b/cw_core/lib/node.dart index aa7ce07a6..04cc0a904 100644 --- a/cw_core/lib/node.dart +++ b/cw_core/lib/node.dart @@ -8,18 +8,18 @@ import 'package:http/io_client.dart' as ioc; part 'node.g.dart'; -Uri createUriFromElectrumAddress(String address) => - Uri.tryParse('tcp://$address')!; +Uri createUriFromElectrumAddress(String address) => Uri.tryParse('tcp://$address')!; @HiveType(typeId: Node.typeId) class Node extends HiveObject with Keyable { - Node( - {this.login, - this.password, - this.useSSL, - this.trusted = false, - String? uri, - WalletType? type,}) { + Node({ + this.login, + this.password, + this.useSSL, + this.trusted = false, + String? uri, + WalletType? type, + }) { if (uri != null) { uriRaw = uri; } @@ -71,7 +71,12 @@ class Node extends HiveObject with Keyable { case WalletType.ethereum: return Uri.https(uriRaw, ''); case WalletType.nano: - return Uri.https(uriRaw, ''); + case WalletType.banano: + if (uriRaw.contains("https") || uriRaw.endsWith("443") || isSSL) { + return Uri.https(uriRaw, ''); + } else { + return Uri.http(uriRaw, ''); + } default: throw Exception('Unexpected type ${type.toString()} for Node uri'); } @@ -80,12 +85,12 @@ class Node extends HiveObject with Keyable { @override bool operator ==(other) => other is Node && - (other.uriRaw == uriRaw && - other.login == login && - other.password == password && - other.typeRaw == typeRaw && - other.useSSL == useSSL && - other.trusted == trusted); + (other.uriRaw == uriRaw && + other.login == login && + other.password == password && + other.typeRaw == typeRaw && + other.useSSL == useSSL && + other.trusted == trusted); @override int get hashCode => @@ -133,27 +138,23 @@ class Node extends HiveObject with Keyable { final path = '/json_rpc'; final rpcUri = isSSL ? Uri.https(uri.authority, path) : Uri.http(uri.authority, path); final realm = 'monero-rpc'; - final body = { - 'jsonrpc': '2.0', - 'id': '0', - 'method': 'get_info' - }; + final body = {'jsonrpc': '2.0', 'id': '0', 'method': 'get_info'}; try { final authenticatingClient = HttpClient(); authenticatingClient.addCredentials( - rpcUri, - realm, - HttpClientDigestCredentials(login ?? '', password ?? ''), + rpcUri, + realm, + HttpClientDigestCredentials(login ?? '', password ?? ''), ); final http.Client client = ioc.IOClient(authenticatingClient); final response = await client.post( - rpcUri, - headers: {'Content-Type': 'application/json'}, - body: json.encode(body), + rpcUri, + headers: {'Content-Type': 'application/json'}, + body: json.encode(body), ); client.close(); diff --git a/cw_nano/lib/nano_client.dart b/cw_nano/lib/nano_client.dart index 66f8ccd0b..05866565d 100644 --- a/cw_nano/lib/nano_client.dart +++ b/cw_nano/lib/nano_client.dart @@ -20,7 +20,6 @@ class NanoClient { static const String DEFAULT_REPRESENTATIVE = "nano_38713x95zyjsqzx6nm1dsom1jmm668owkeb9913ax6nfgj15az3nu8xkx579"; - // final _httpClient = http.Client(); StreamSubscription? subscription; Node? _node; @@ -195,6 +194,160 @@ class NanoClient { } } + Future receiveBlock({ + required String blockHash, + required String source, + required String amountRaw, + required String destinationAddress, + required String privateKey, + }) async { + bool openBlock = false; + + final headers = { + "Content-Type": "application/json", + }; + + // first check if the account is open: + // get the account info (we need the frontier and representative): + final infoBody = jsonEncode({ + "action": "account_info", + "representative": "true", + "account": destinationAddress, + }); + final infoResponse = await http.post( + _node!.uri, + headers: headers, + body: infoBody, + ); + final infoData = jsonDecode(infoResponse.body); + + if (infoData["error"] != null) { + // account is not open yet, we need to create an open block: + openBlock = true; + } + + // first get the account balance: + final balanceBody = jsonEncode({ + "action": "account_balance", + "account": destinationAddress, + }); + + final balanceResponse = await http.post( + _node!.uri, + headers: headers, + body: balanceBody, + ); + + final balanceData = jsonDecode(balanceResponse.body); + final BigInt currentBalance = BigInt.parse(balanceData["balance"].toString()); + final BigInt txAmount = BigInt.parse(amountRaw); + final BigInt balanceAfterTx = currentBalance + txAmount; + + String frontier = infoData["frontier"].toString(); + String representative = infoData["representative"].toString(); + + if (openBlock) { + // we don't have a representative set yet: + representative = DEFAULT_REPRESENTATIVE; + } + + // link = send block hash: + final String link = blockHash; + // this "linkAsAccount" is meaningless: + final String linkAsAccount = NanoAccounts.createAccount(NanoAccountType.NANO, blockHash); + + // construct the receive block: + Map receiveBlock = { + "type": "state", + "account": destinationAddress, + "previous": + openBlock ? "0000000000000000000000000000000000000000000000000000000000000000" : frontier, + "representative": representative, + "balance": balanceAfterTx.toString(), + "link": link, + "link_as_account": linkAsAccount, + }; + + // sign the receive block: + final String hash = NanoBlocks.computeStateHash( + NanoAccountType.NANO, + receiveBlock["account"]!, + receiveBlock["previous"]!, + receiveBlock["representative"]!, + BigInt.parse(receiveBlock["balance"]!), + receiveBlock["link"]!, + ); + final String signature = NanoSignatures.signBlock(hash, privateKey); + + // get PoW for the receive block: + String? work; + if (openBlock) { + work = await requestWork(NanoAccounts.extractPublicKey(destinationAddress)); + } else { + work = await requestWork(frontier); + } + receiveBlock["link_as_account"] = linkAsAccount; + receiveBlock["signature"] = signature; + receiveBlock["work"] = work; + + // process the receive block: + + final processBody = jsonEncode({ + "action": "process", + "json_block": "true", + "subtype": "receive", + "block": receiveBlock, + }); + final processResponse = await http.post( + _node!.uri, + headers: headers, + body: processBody, + ); + + final Map decoded = json.decode(processResponse.body) as Map; + if (decoded.containsKey("error")) { + throw Exception("Received error ${decoded["error"]}"); + } + } + + // returns the number of blocks received: + Future confirmAllReceivable({ + required String destinationAddress, + required String privateKey, + }) async { + final receivableResponse = await http.post(_node!.uri, + headers: {"Content-Type": "application/json"}, + body: jsonEncode({ + "action": "receivable", + "source": "true", + "account": destinationAddress, + "count": "-1", + })); + + final receivableData = await jsonDecode(receivableResponse.body); + if (receivableData["blocks"] == "") { + return 0; + } + final blocks = receivableData["blocks"] as Map; + // confirm all receivable blocks: + for (final blockHash in blocks.keys) { + final block = blocks[blockHash]; + final String amountRaw = block["amount"] as String; + final String source = block["source"] as String; + await receiveBlock( + blockHash: blockHash, + source: source, + amountRaw: amountRaw, + privateKey: privateKey, + destinationAddress: destinationAddress, + ); + // a bit of a hack: + await Future.delayed(const Duration(seconds: 2)); + } + + return blocks.keys.length; + } + Future getTransactionDetails(String transactionHash) async { throw UnimplementedError(); } @@ -219,8 +372,7 @@ class NanoClient { // Map the transactions list to NanoTransactionModel using the factory // reversed so that the DateTime is correct when local_timestamp is absent return transactions.reversed - .map( - (transaction) => NanoTransactionModel.fromJson(transaction as Map)) + .map((transaction) => NanoTransactionModel.fromJson(transaction)) .toList(); } catch (e) { print(e); diff --git a/cw_nano/lib/nano_transaction_model.dart b/cw_nano/lib/nano_transaction_model.dart index 9f7556f96..e9c59da5a 100644 --- a/cw_nano/lib/nano_transaction_model.dart +++ b/cw_nano/lib/nano_transaction_model.dart @@ -17,17 +17,17 @@ class NanoTransactionModel { required this.account, }); - factory NanoTransactionModel.fromJson(Map json) { - DateTime? local_timestamp; + factory NanoTransactionModel.fromJson(dynamic json) { + DateTime? localTimestamp; try { - local_timestamp = - DateTime.fromMillisecondsSinceEpoch(int.parse(json["local_timeStamp"] as String) * 1000); + localTimestamp = DateTime.fromMillisecondsSinceEpoch( + int.parse(json["local_timestamp"] as String) * 1000); } catch (e) { - local_timestamp = DateTime.now(); + localTimestamp = DateTime.now(); } return NanoTransactionModel( - date: local_timestamp, + date: localTimestamp, hash: json["hash"] as String, height: int.parse(json["height"] as String), type: json["type"] as String, diff --git a/cw_nano/lib/nano_wallet.dart b/cw_nano/lib/nano_wallet.dart index e6be68a16..047be1d97 100644 --- a/cw_nano/lib/nano_wallet.dart +++ b/cw_nano/lib/nano_wallet.dart @@ -117,7 +117,8 @@ abstract class NanoWalletBase throw Exception("Ethereum Node connection failed"); } // _client.setListeners(_privateKey.address, _onNewTransaction); - _updateBalance(); + await _updateBalance(); + await _receiveAll(); syncStatus = ConnectedSyncStatus(); } catch (e) { syncStatus = FailedSyncStatus(); @@ -184,6 +185,19 @@ abstract class NanoWalletBase ); } + Future _receiveAll() async { + int blocksReceived = await this._client.confirmAllReceivable( + destinationAddress: _publicAddress, + privateKey: _privateKey, + ); + + if (blocksReceived > 0) { + await Future.delayed(Duration(seconds: 3)); + _updateBalance(); + updateTransactions(); + } + } + Future updateTransactions() async { try { if (_isTransactionUpdating) { @@ -255,6 +269,10 @@ abstract class NanoWalletBase syncStatus = AttemptingSyncStatus(); await _updateBalance(); await updateTransactions(); + Timer.periodic( + const Duration(minutes: 1), + (timer) async => await _receiveAll(), + ); syncStatus = SyncedSyncStatus(); } catch (e) { diff --git a/lib/nano/cw_nano.dart b/lib/nano/cw_nano.dart index ae64c4426..f3a5be736 100644 --- a/lib/nano/cw_nano.dart +++ b/lib/nano/cw_nano.dart @@ -1,20 +1,13 @@ part of 'nano.dart'; class CWNano extends Nano { - // @override - // NanoAccountList getAccountList(Object wallet) { - // return CWNanoAccountList(wallet); - // } - @override List getNanoWordList(String language) { - // throw UnimplementedError(); return NanoMnemomics.WORDLIST; } @override WalletService createNanoWalletService(Box walletInfoSource) { - print("creating NanoWalletService"); return NanoWalletService(walletInfoSource); } @@ -61,15 +54,11 @@ class CWNano extends Nano { @override TransactionHistoryBase getTransactionHistory(Object wallet) { - // final moneroWallet = wallet as MoneroWallet; - // return moneroWallet.transactionHistory; throw UnimplementedError(); } @override - void onStartup() { - // monero_wallet_api.onStartup(); - } + void onStartup() {} @override Object createNanoTransactionCredentials(List outputs) { diff --git a/lib/src/screens/send/widgets/send_card.dart b/lib/src/screens/send/widgets/send_card.dart index 1a504b875..3704bca4e 100644 --- a/lib/src/screens/send/widgets/send_card.dart +++ b/lib/src/screens/send/widgets/send_card.dart @@ -449,78 +449,79 @@ class SendCardState extends State with AutomaticKeepAliveClientMixin GestureDetector( - onTap: () => _setTransactionPriority(context), - child: Container( - padding: EdgeInsets.only(top: 24), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - S.of(context).send_estimated_fee, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - //color: Theme.of(context).primaryTextTheme!.displaySmall!.color!, - color: Colors.white), - ), - Container( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - output.estimatedFee.toString() + - ' ' + - sendViewModel.selectedCryptoCurrency.toString(), - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - //color: Theme.of(context).primaryTextTheme!.displaySmall!.color!, - color: Colors.white, - ), - ), - Padding( - padding: EdgeInsets.only(top: 5), - child: sendViewModel.isFiatDisabled - ? const SizedBox(height: 14) - : Text( - output.estimatedFeeFiatAmount + - ' ' + - sendViewModel.fiat.title, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Theme.of(context) - .primaryTextTheme - .headlineSmall! - .decorationColor!, - ), - ), - ), - ], - ), - Padding( - padding: EdgeInsets.only(top: 2, left: 5), - child: Icon( - Icons.arrow_forward_ios, - size: 12, - color: Colors.white, - ), - ) - ], + if (sendViewModel.hasFees) + Observer( + builder: (_) => GestureDetector( + onTap: () => _setTransactionPriority(context), + child: Container( + padding: EdgeInsets.only(top: 24), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + S.of(context).send_estimated_fee, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + //color: Theme.of(context).primaryTextTheme!.displaySmall!.color!, + color: Colors.white), ), - ) - ], + Container( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + output.estimatedFee.toString() + + ' ' + + sendViewModel.selectedCryptoCurrency.toString(), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + //color: Theme.of(context).primaryTextTheme!.displaySmall!.color!, + color: Colors.white, + ), + ), + Padding( + padding: EdgeInsets.only(top: 5), + child: sendViewModel.isFiatDisabled + ? const SizedBox(height: 14) + : Text( + output.estimatedFeeFiatAmount + + ' ' + + sendViewModel.fiat.title, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .primaryTextTheme + .headlineSmall! + .decorationColor!, + ), + ), + ), + ], + ), + Padding( + padding: EdgeInsets.only(top: 2, left: 5), + child: Icon( + Icons.arrow_forward_ios, + size: 12, + color: Colors.white, + ), + ) + ], + ), + ) + ], + ), ), ), ), - ), if (sendViewModel.isElectrumWallet) Padding( padding: EdgeInsets.only(top: 6), diff --git a/lib/view_model/send/send_view_model.dart b/lib/view_model/send/send_view_model.dart index f74408acd..ac0946826 100644 --- a/lib/view_model/send/send_view_model.dart +++ b/lib/view_model/send/send_view_model.dart @@ -165,6 +165,9 @@ abstract class SendViewModelBase with Store { bool get isElectrumWallet => _wallet.type == WalletType.bitcoin || _wallet.type == WalletType.litecoin; + @computed + bool get hasFees => _wallet.type != WalletType.nano && _wallet.type != WalletType.banano; + @observable CryptoCurrency selectedCryptoCurrency; diff --git a/lib/view_model/transaction_details_view_model.dart b/lib/view_model/transaction_details_view_model.dart index bb6008754..5492a6707 100644 --- a/lib/view_model/transaction_details_view_model.dart +++ b/lib/view_model/transaction_details_view_model.dart @@ -116,6 +116,10 @@ abstract class TransactionDetailsViewModelBase with Store { return 'https://explorer.havenprotocol.org/search?value=${txId}'; case WalletType.ethereum: return 'https://etherscan.io/tx/${txId}'; + case WalletType.nano: + return 'https://nanolooker.com/block/${txId}'; + case WalletType.banano: + return 'https://bananolooker.com/block/${txId}'; default: return ''; } @@ -133,6 +137,10 @@ abstract class TransactionDetailsViewModelBase with Store { return S.current.view_transaction_on + 'explorer.havenprotocol.org'; case WalletType.ethereum: return S.current.view_transaction_on + 'etherscan.io'; + case WalletType.nano: + return S.current.view_transaction_on + 'nanolooker.com'; + case WalletType.banano: + return S.current.view_transaction_on + 'bananolooker.com'; default: return ''; }