import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:isar/isar.dart'; import 'package:nanodart/nanodart.dart'; import 'package:stackwallet/db/isar/main_db.dart'; import 'package:stackwallet/models/balance.dart'; import 'package:stackwallet/models/isar/models/isar_models.dart'; import 'package:stackwallet/models/node_model.dart'; import 'package:stackwallet/models/paymint/fee_object_model.dart'; import 'package:stackwallet/services/coins/coin_service.dart'; import 'package:stackwallet/services/event_bus/events/global/node_connection_status_changed_event.dart'; import 'package:stackwallet/services/event_bus/events/global/updated_in_background_event.dart'; import 'package:stackwallet/services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import 'package:stackwallet/services/event_bus/global_event_bus.dart'; import 'package:stackwallet/services/mixins/coin_control_interface.dart'; import 'package:stackwallet/services/mixins/wallet_cache.dart'; import 'package:stackwallet/services/mixins/wallet_db.dart'; import 'package:stackwallet/services/node_service.dart'; import 'package:stackwallet/services/transaction_notification_tracker.dart'; import 'package:stackwallet/utilities/amount/amount.dart'; import 'package:stackwallet/utilities/constants.dart'; import 'package:stackwallet/utilities/default_nodes.dart'; import 'package:stackwallet/utilities/enums/coin_enum.dart'; import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/utilities/prefs.dart'; import 'package:tuple/tuple.dart'; const int MINIMUM_CONFIRMATIONS = 1; const String DEFAULT_REPRESENTATIVE = "ban_1ka1ium4pfue3uxtntqsrib8mumxgazsjf58gidh1xeo5te3whsq8z476goo"; class BananoWallet extends CoinServiceAPI with WalletCache, WalletDB, CoinControlInterface { BananoWallet({ required String walletId, required String walletName, required Coin coin, required TransactionNotificationTracker tracker, required SecureStorageInterface secureStore, MainDB? mockableOverride, }) { txTracker = tracker; _walletId = walletId; _walletName = walletName; _coin = coin; _secureStore = secureStore; initCache(walletId, coin); initWalletDB(mockableOverride: mockableOverride); } NodeModel? _xnoNode; @override Future get mnemonicPassphrase => _secureStore.read( key: '${_walletId}_mnemonicPassphrase', ); @override Future get mnemonicString => _secureStore.read(key: '${_walletId}_mnemonic'); Future getSeedFromMnemonic() async { var mnemonic = await mnemonicString; return NanoMnemomics.mnemonicListToSeed(mnemonic!.split(" ")); } Future getPrivateKeyFromMnemonic() async { var mnemonic = await mnemonicString; var seed = NanoMnemomics.mnemonicListToSeed(mnemonic!.split(" ")); return NanoKeys.seedToPrivate(seed, 0); } Future getAddressFromMnemonic() async { var mnemonic = await mnemonicString; var seed = NanoMnemomics.mnemonicListToSeed(mnemonic!.split(' ')); var address = NanoAccounts.createAccount(NanoAccountType.BANANO, NanoKeys.createPublicKey(NanoKeys.seedToPrivate(seed, 0))); return address; } Future getPublicKeyFromMnemonic() async { var mnemonic = await mnemonicString; if (mnemonic == null) { return ""; } else { var seed = NanoMnemomics.mnemonicListToSeed(mnemonic.split(" ")); return NanoKeys.createPublicKey(NanoKeys.seedToPrivate(seed, 0)); } } @override String get walletId => _walletId; late String _walletId; @override String get walletName => _walletName; late String _walletName; @override set walletName(String name) => _walletName = name; @override set isFavorite(bool markFavorite) { _isFavorite = markFavorite; updateCachedIsFavorite(markFavorite); } @override bool get isFavorite => _isFavorite ??= getCachedIsFavorite(); bool? _isFavorite; @override Coin get coin => _coin; late Coin _coin; late SecureStorageInterface _secureStore; late final TransactionNotificationTracker txTracker; final _prefs = Prefs.instance; Timer? timer; bool _shouldAutoSync = false; @override bool get shouldAutoSync => _shouldAutoSync; @override set shouldAutoSync(bool shouldAutoSync) { if (_shouldAutoSync != shouldAutoSync) { _shouldAutoSync = shouldAutoSync; if (!shouldAutoSync) { timer?.cancel(); timer = null; stopNetworkAlivePinging(); } else { startNetworkAlivePinging(); refresh(); } } } @override Balance get balance => _balance ??= getCachedBalance(); Balance? _balance; Future requestWork(String hash) async { return http .post( Uri.parse("https://rpc.nano.to"), // this should be a headers: {'Content-type': 'application/json'}, body: json.encode( { "action": "work_generate", "hash": hash, }, ), ) .then((http.Response response) { if (response.statusCode == 200) { final Map decoded = json.decode(response.body) as Map; if (decoded.containsKey("error")) { throw Exception("Received error ${decoded["error"]}"); } return decoded["work"] as String?; } else { throw Exception("Received error ${response.statusCode}"); } }); } @override Future confirmSend({required Map txData}) async { try { // our address: final String publicAddress = await currentReceivingAddress; // first get the account balance: final balanceBody = jsonEncode({ "action": "account_balance", "account": publicAddress, }); final headers = { "Content-Type": "application/json", }; final balanceResponse = await http.post( Uri.parse(getCurrentNode().host), headers: headers, body: balanceBody, ); final balanceData = jsonDecode(balanceResponse.body); final BigInt currentBalance = BigInt.parse(balanceData["balance"].toString()); final BigInt txAmount = txData["recipientAmt"].raw as BigInt; final BigInt balanceAfterTx = currentBalance - txAmount; // get the account info (we need the frontier and representative): final infoBody = jsonEncode({ "action": "account_info", "representative": "true", "account": publicAddress, }); final infoResponse = await http.post( Uri.parse(getCurrentNode().host), headers: headers, body: infoBody, ); final String frontier = jsonDecode(infoResponse.body)["frontier"].toString(); final String representative = jsonDecode(infoResponse.body)["representative"].toString(); // link = destination address: final String link = NanoAccounts.extractPublicKey(txData["address"].toString()); final String linkAsAccount = txData["address"].toString(); // construct the send block: Map sendBlock = { "type": "state", "account": publicAddress, "previous": frontier, "representative": representative, "balance": balanceAfterTx.toString(), "link": link, }; // sign the send block: final String hash = NanoBlocks.computeStateHash( NanoAccountType.BANANO, sendBlock["account"]!, sendBlock["previous"]!, sendBlock["representative"]!, BigInt.parse(sendBlock["balance"]!), sendBlock["link"]!, ); final String privateKey = await getPrivateKeyFromMnemonic(); final String signature = NanoSignatures.signBlock(hash, privateKey); // get PoW for the send block: final String? work = await requestWork(frontier); if (work == null) { throw Exception("Failed to get PoW for send block"); } sendBlock["link_as_account"] = linkAsAccount; sendBlock["signature"] = signature; sendBlock["work"] = work; final processBody = jsonEncode({ "action": "process", "json_block": "true", "subtype": "send", "block": sendBlock, }); final processResponse = await http.post( Uri.parse(getCurrentNode().host), headers: headers, body: processBody, ); final Map decoded = json.decode(processResponse.body) as Map; if (decoded.containsKey("error")) { throw Exception("Received error ${decoded["error"]}"); } // return the hash of the transaction: return decoded["hash"].toString(); } catch (e, s) { Logging.instance .log("Error sending transaction $e - $s", level: LogLevel.Error); rethrow; } } Future get _currentReceivingAddress => db .getAddresses(walletId) .filter() .typeEqualTo(AddressType.banano) .and() .subTypeEqualTo(AddressSubType.receiving) .sortByDerivationIndexDesc() .findFirst(); @override Future get currentReceivingAddress async => (await _currentReceivingAddress)?.value ?? await getAddressFromMnemonic(); @override Future estimateFeeFor(Amount amount, int feeRate) { // fees are always 0 :) return Future.value( Amount( rawValue: BigInt.from(0), fractionDigits: coin.decimals, ), ); } @override Future exit() async { _hasCalledExit = true; } @override // Banano has no fees Future get fees async => FeeObject( numberOfBlocksFast: 1, numberOfBlocksAverage: 1, numberOfBlocksSlow: 1, fast: 0, medium: 0, slow: 0, ); Future updateBalance() async { final body = jsonEncode({ "action": "account_balance", "account": await currentReceivingAddress, }); final headers = { "Content-Type": "application/json", }; final response = await http.post(Uri.parse(getCurrentNode().host), headers: headers, body: body); final data = jsonDecode(response.body); _balance = Balance( total: Amount( rawValue: (BigInt.parse(data["balance"].toString()) + BigInt.parse(data["receivable"].toString())), fractionDigits: coin.decimals), spendable: Amount( rawValue: BigInt.parse(data["balance"].toString()), fractionDigits: coin.decimals), blockedTotal: Amount(rawValue: BigInt.parse("0"), fractionDigits: coin.decimals), pendingSpendable: Amount( rawValue: BigInt.parse(data["receivable"].toString()), fractionDigits: coin.decimals), ); await updateCachedBalance(_balance!); } Future receiveBlock( String blockHash, String source, String amountRaw) async { // TODO: the opening block of an account is a special case bool openBlock = false; final headers = { "Content-Type": "application/json", }; // our address: final String publicAddress = await currentReceivingAddress; // 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": publicAddress, }); final infoResponse = await http.post( Uri.parse(getCurrentNode().host), 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": publicAddress, }); final balanceResponse = await http.post( Uri.parse(getCurrentNode().host), 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.BANANO, blockHash); // construct the receive block: Map receiveBlock = { "type": "state", "account": publicAddress, "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.BANANO, receiveBlock["account"]!, receiveBlock["previous"]!, receiveBlock["representative"]!, BigInt.parse(receiveBlock["balance"]!), receiveBlock["link"]!, ); final String privateKey = await getPrivateKeyFromMnemonic(); final String signature = NanoSignatures.signBlock(hash, privateKey); // get PoW for the receive block: String? work; if (openBlock) { work = await requestWork(NanoAccounts.extractPublicKey(publicAddress)); } else { work = await requestWork(frontier); } if (work == null) { throw Exception("Failed to get PoW for receive block"); } 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( Uri.parse(getCurrentNode().host), headers: headers, body: processBody, ); final Map decoded = json.decode(processResponse.body) as Map; if (decoded.containsKey("error")) { throw Exception("Received error ${decoded["error"]}"); } } Future confirmAllReceivable() async { final receivableResponse = await http.post(Uri.parse(getCurrentNode().host), headers: {"Content-Type": "application/json"}, body: jsonEncode({ "action": "receivable", "source": "true", "account": await currentReceivingAddress, "count": "-1", })); final receivableData = await jsonDecode(receivableResponse.body); if (receivableData["blocks"] == "") { return; } 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, source, amountRaw); // a bit of a hack: await Future.delayed(const Duration(seconds: 1)); } } Future updateTransactions() async { await confirmAllReceivable(); final receivingAddress = (await _currentReceivingAddress)!; final String publicAddress = receivingAddress.value; final response = await http.post(Uri.parse(getCurrentNode().host), headers: {"Content-Type": "application/json"}, body: jsonEncode({ "action": "account_history", "account": publicAddress, "count": "-1", })); final data = await jsonDecode(response.body); final transactions = data["history"] is List ? data["history"] as List : []; if (transactions.isEmpty) { return; } else { List> transactionList = []; for (var tx in transactions) { var typeString = tx["type"].toString(); TransactionType transactionType = TransactionType.unknown; if (typeString == "send") { transactionType = TransactionType.outgoing; } else if (typeString == "receive") { transactionType = TransactionType.incoming; } final amount = Amount( rawValue: BigInt.parse(tx["amount"].toString()), fractionDigits: coin.decimals, ); var transaction = Transaction( walletId: walletId, txid: tx["hash"].toString(), timestamp: int.parse(tx["local_timestamp"].toString()), type: transactionType, subType: TransactionSubType.none, amount: 0, amountString: amount.toJsonString(), fee: 0, height: int.parse(tx["height"].toString()), isCancelled: false, isLelantus: false, slateId: "", otherData: "", inputs: [], outputs: [], nonce: 0, numberOfMessages: null, ); Address address = transactionType == TransactionType.incoming ? receivingAddress : Address( walletId: walletId, publicKey: [], value: tx["account"].toString(), derivationIndex: 0, derivationPath: null, type: AddressType.banano, subType: AddressSubType.nonWallet, ); Tuple2 tuple = Tuple2(transaction, address); transactionList.add(tuple); } await db.addNewTransactionData(transactionList, walletId); if (transactionList.isNotEmpty) { GlobalEventBus.instance.fire( UpdatedInBackgroundEvent( "Transactions updated/added for: $walletId $walletName ", walletId, ), ); } } } @override Future fullRescan( int maxUnusedAddressGap, int maxNumberOfIndexesToCheck) async { await _prefs.init(); await updateTransactions(); await updateBalance(); } @override Future generateNewAddress() { // TODO: implement generateNewAddress throw UnimplementedError(); } @override bool get hasCalledExit => _hasCalledExit; bool _hasCalledExit = false; @override Future initializeExisting() async { await _prefs.init(); } @override Future initializeNew() async { if ((await mnemonicString) != null || (await mnemonicPassphrase) != null) { throw Exception( "Attempted to overwrite mnemonic on generate new wallet!"); } await _prefs.init(); String seed = NanoSeeds.generateSeed(); final mnemonic = NanoMnemomics.seedToMnemonic(seed); await _secureStore.write( key: '${_walletId}_mnemonic', value: mnemonic.join(' '), ); await _secureStore.write( key: '${_walletId}_mnemonicPassphrase', value: "", ); String privateKey = NanoKeys.seedToPrivate(seed, 0); String publicKey = NanoKeys.createPublicKey(privateKey); String publicAddress = NanoAccounts.createAccount(NanoAccountType.BANANO, publicKey); final address = Address( walletId: walletId, value: publicAddress, publicKey: [], // TODO: add public key derivationIndex: 0, derivationPath: null, type: AddressType.banano, subType: AddressSubType.receiving, ); await db.putAddress(address); await Future.wait( [updateCachedId(walletId), updateCachedIsFavorite(false)]); } @override bool get isConnected => _isConnected; bool _isConnected = false; @override bool get isRefreshing => refreshMutex; bool refreshMutex = false; @override Future get maxFee => Future.value(0); @override Future> get mnemonic => _getMnemonicList(); Future> _getMnemonicList() async { final _mnemonicString = await mnemonicString; if (_mnemonicString == null) { return []; } final List data = _mnemonicString.split(' '); return data; } @override Future> prepareSend({ required String address, required Amount amount, Map? args, }) async { try { if (amount.decimals != coin.decimals) { throw ArgumentError("Banano prepareSend attempted with invalid Amount"); } Map txData = { "fee": 0, "addresss": address, "recipientAmt": amount, }; Logging.instance.log("prepare send: $txData", level: LogLevel.Info); return txData; } catch (e, s) { Logging.instance.log("Error getting fees $e - $s", level: LogLevel.Error); rethrow; } } @override Future recoverFromMnemonic( {required String mnemonic, String? mnemonicPassphrase, required int maxUnusedAddressGap, required int maxNumberOfIndexesToCheck, required int height}) async { try { if ((await mnemonicString) != null || (await this.mnemonicPassphrase) != null) { throw Exception("Attempted to overwrite mnemonic on restore!"); } await _secureStore.write( key: '${_walletId}_mnemonic', value: mnemonic.trim()); await _secureStore.write( key: '${_walletId}_mnemonicPassphrase', value: mnemonicPassphrase ?? "", ); String seed = NanoMnemomics.mnemonicListToSeed(mnemonic.split(" ")); String privateKey = NanoKeys.seedToPrivate(seed, 0); String publicKey = NanoKeys.createPublicKey(privateKey); String publicAddress = NanoAccounts.createAccount(NanoAccountType.BANANO, publicKey); final address = Address( walletId: walletId, value: publicAddress, publicKey: [], derivationIndex: 0, derivationPath: null, type: AddressType.banano, subType: AddressSubType.receiving, ); await db.putAddress(address); await Future.wait( [updateCachedId(walletId), updateCachedIsFavorite(false)]); } catch (e) { rethrow; } } @override Future refresh() async { if (refreshMutex) { Logging.instance.log( "$walletId $walletName refreshMutex denied", level: LogLevel.Info, ); return; } else { refreshMutex = true; } await _prefs.init(); try { GlobalEventBus.instance.fire( WalletSyncStatusChangedEvent( WalletSyncStatus.syncing, walletId, coin, ), ); await _prefs.init(); await updateChainHeight(); await updateTransactions(); await updateBalance(); GlobalEventBus.instance.fire( WalletSyncStatusChangedEvent( WalletSyncStatus.synced, walletId, coin, ), ); } catch (e, s) { Logging.instance.log( "Failed to refresh banano wallet $walletId: '$walletName': $e\n$s", level: LogLevel.Warning, ); GlobalEventBus.instance.fire( WalletSyncStatusChangedEvent( WalletSyncStatus.unableToSync, walletId, coin, ), ); } refreshMutex = false; } @override int get storedChainHeight => getCachedChainHeight(); NodeModel getCurrentNode() { return _xnoNode ?? NodeService(secureStorageInterface: _secureStore) .getPrimaryNodeFor(coin: coin) ?? DefaultNodes.getNodeFor(coin); } @override Future testNetworkConnection() async { final uri = Uri.parse(getCurrentNode().host); final response = await http.post( uri, headers: {"Content-Type": "application/json"}, body: jsonEncode( { "action": "version", }, ), ); return response.statusCode == 200; } Timer? _networkAliveTimer; void startNetworkAlivePinging() { // call once on start right away _periodicPingCheck(); // then periodically check _networkAliveTimer = Timer.periodic( Constants.networkAliveTimerDuration, (_) async { _periodicPingCheck(); }, ); } void _periodicPingCheck() async { bool hasNetwork = await testNetworkConnection(); if (_isConnected != hasNetwork) { NodeConnectionStatus status = hasNetwork ? NodeConnectionStatus.connected : NodeConnectionStatus.disconnected; GlobalEventBus.instance.fire( NodeConnectionStatusChangedEvent( status, walletId, coin, ), ); _isConnected = hasNetwork; if (hasNetwork) { unawaited(refresh()); } } } void stopNetworkAlivePinging() { _networkAliveTimer?.cancel(); _networkAliveTimer = null; } @override Future> get transactions => db.getTransactions(walletId).findAll(); @override Future updateNode(bool shouldRefresh) async { _xnoNode = NodeService(secureStorageInterface: _secureStore) .getPrimaryNodeFor(coin: coin) ?? DefaultNodes.getNodeFor(coin); if (shouldRefresh) { unawaited(refresh()); } } @override Future updateSentCachedTxData(Map txData) async { // not currently used for nano return; } @override // TODO: implement utxos Future> get utxos => throw UnimplementedError(); @override bool validateAddress(String address) { return NanoAccounts.isValid(NanoAccountType.BANANO, address); } Future updateChainHeight() async { final String publicAddress = await currentReceivingAddress; // first get the account balance: final infoBody = jsonEncode({ "action": "account_info", "account": publicAddress, }); final headers = { "Content-Type": "application/json", }; final infoResponse = await http.post( Uri.parse(getCurrentNode().host), headers: headers, body: infoBody, ); final infoData = jsonDecode(infoResponse.body); final int? height = int.tryParse( infoData["confirmation_height"].toString(), ); await updateCachedChainHeight(height ?? 0); } }