import 'dart:async'; import 'dart:convert'; import 'package:cw_core/nano_account_info_response.dart'; import 'package:cw_nano/nano_block_info_response.dart'; import 'package:cw_core/n2_node.dart'; import 'package:cw_nano/nano_balance.dart'; import 'package:cw_nano/nano_transaction_model.dart'; import 'package:http/http.dart' as http; import 'package:cw_core/node.dart'; import 'package:nanoutil/nanoutil.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:cw_nano/.secrets.g.dart' as nano_secrets; class NanoClient { static const Map CAKE_HEADERS = { "Content-Type": "application/json", "nano-app": "cake-wallet" }; static const String N2_REPS_ENDPOINT = "https://rpc.nano.to"; NanoClient() { SharedPreferences.getInstance().then((value) => prefs = value); } late SharedPreferences prefs; Node? _node; Node? _powNode; static const String _defaultDefaultRepresentative = "nano_38713x95zyjsqzx6nm1dsom1jmm668owkeb9913ax6nfgj15az3nu8xkx579"; String getRepFromPrefs() { // from preferences_key.dart "defaultNanoRep" key: return prefs.getString("default_nano_representative") ?? _defaultDefaultRepresentative; } bool connect(Node node) { try { _node = node; return true; } catch (e) { return false; } } bool connectPow(Node node) { try { _powNode = node; return true; } catch (e) { return false; } } Map getHeaders() { final headers = Map.from(CAKE_HEADERS); if (_node!.uri.host == "rpc.nano.to") { headers["key"] = nano_secrets.nano2ApiKey; } if (_node!.uri.host == "nano.nownodes.io") { headers["api-key"] = nano_secrets.nanoNowNodesApiKey; } return headers; } Future getBalance(String address) async { final response = await http.post( _node!.uri, headers: getHeaders(), body: jsonEncode( { "action": "account_balance", "account": address, }, ), ); final data = await 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"] : ""}"); } final String currentBalance = data["balance"] as String; final String receivableBalance = data["receivable"] as String; final BigInt cur = BigInt.parse(currentBalance); final BigInt rec = BigInt.parse(receivableBalance); return NanoBalance(currentBalance: cur, receivableBalance: rec); } Future getAccountInfo(String address) async { try { final response = await http.post( _node!.uri, headers: getHeaders(), body: jsonEncode( { "action": "account_info", "representative": "true", "account": address, }, ), ); final data = await jsonDecode(response.body); return AccountInfoResponse.fromJson(data as Map); } catch (e) { print("error while getting account info $e"); return null; } } Future getBlockContents(String block) async { try { final response = await http.post( _node!.uri, headers: CAKE_HEADERS, body: jsonEncode( { "action": "block_info", "json_block": "true", "hash": block, }, ), ); final data = await jsonDecode(response.body); return BlockContentsResponse.fromJson(data["contents"] as Map); } catch (e) { print("error while getting block info $e"); return null; } } Future changeRep({ required String privateKey, required String repAddress, required String ourAddress, }) async { AccountInfoResponse? accountInfo = await getAccountInfo(ourAddress); if (accountInfo == null) { throw Exception( "error while getting account info, you can't change the rep of an unopened account"); } // construct the change block: Map changeBlock = { "type": "state", "account": ourAddress, "previous": accountInfo.frontier, "representative": repAddress, "balance": accountInfo.balance, "link": "0000000000000000000000000000000000000000000000000000000000000000", "link_as_account": "nano_1111111111111111111111111111111111111111111111111111hifc8npp", }; // sign the change block: final String hash = NanoSignatures.computeStateHash( NanoBasedCurrency.NANO, changeBlock["account"]!, changeBlock["previous"]!, changeBlock["representative"]!, BigInt.parse(changeBlock["balance"]!), changeBlock["link"]!, ); final String signature = NanoSignatures.signBlock(hash, privateKey); // get PoW for the send block: final String work = await requestWork(accountInfo.frontier); changeBlock["signature"] = signature; changeBlock["work"] = work; try { return await processBlock(changeBlock, "change"); } catch (e) { throw Exception("error while changing representative: $e"); } } Future requestWork(String hash) async { final response = await http.post( _powNode!.uri, headers: getHeaders(), body: json.encode( { "action": "work_generate", "hash": hash, }, ), ); 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 work error ${response.body}"); } } Future send({ required String privateKey, required String amountRaw, required String destinationAddress, }) async { final Map sendBlock = await constructSendBlock( privateKey: privateKey, amountRaw: amountRaw, destinationAddress: destinationAddress, ); return await processBlock(sendBlock, "send"); } Future processBlock(Map block, String subtype) async { final processBody = jsonEncode({ "action": "process", "json_block": "true", "subtype": subtype, "block": block, }); final processResponse = await http.post( _node!.uri, headers: getHeaders(), 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(); } Future> constructSendBlock({ required String privateKey, required String amountRaw, required String destinationAddress, BigInt? balanceAfterTx, String? previousHash, }) async { // our address: final String publicAddress = NanoDerivations.privateKeyToAddress(privateKey); // first get the current account balance: if (balanceAfterTx == null) { final BigInt currentBalance = (await getBalance(publicAddress)).currentBalance; final BigInt txAmount = BigInt.parse(amountRaw); balanceAfterTx = currentBalance - txAmount; } // get the account info (we need the frontier and representative): AccountInfoResponse? infoResponse = await getAccountInfo(publicAddress); if (infoResponse == null) { throw Exception( "error while getting account info! (we probably don't have an open account yet)"); } String frontier = infoResponse.frontier; // override if provided: if (previousHash != null) { frontier = previousHash; } final String representative = infoResponse.representative; // link = destination address: final String link = NanoDerivations.addressToPublicKey(destinationAddress); final String linkAsAccount = destinationAddress; // 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 = NanoSignatures.computeStateHash( NanoBasedCurrency.NANO, sendBlock["account"]!, sendBlock["previous"]!, sendBlock["representative"]!, BigInt.parse(sendBlock["balance"]!), sendBlock["link"]!, ); final String signature = NanoSignatures.signBlock(hash, privateKey); // get PoW for the send block: final String work = await requestWork(frontier); sendBlock["link_as_account"] = linkAsAccount; sendBlock["signature"] = signature; sendBlock["work"] = work; // ready to post send block: return sendBlock; } Future receiveBlock({ required String blockHash, required String amountRaw, required String destinationAddress, required String privateKey, }) async { bool openBlock = false; // first check if the account is open: // get the account info (we need the frontier and representative): AccountInfoResponse? infoData = await getAccountInfo(destinationAddress); String? frontier; String? representative; if (infoData == null) { // account is not open yet, we need to create an open block: openBlock = true; // we don't have a representative set yet: representative = await getRepFromPrefs(); // we don't have a frontier yet: frontier = "0000000000000000000000000000000000000000000000000000000000000000"; } else { frontier = infoData.frontier; representative = infoData.representative; } if ((BigInt.tryParse(amountRaw) ?? BigInt.zero) <= BigInt.zero) { throw Exception("amountRaw must be greater than zero"); } BlockContentsResponse? frontierContents; if (!openBlock) { // get the block info of the frontier block: frontierContents = await getBlockContents(frontier); if (frontierContents == null) { throw Exception("error while getting frontier block info"); } final String frontierHash = NanoSignatures.computeStateHash( NanoBasedCurrency.NANO, frontierContents.account, frontierContents.previous, frontierContents.representative, BigInt.parse(frontierContents.balance), frontierContents.link, ); bool valid = await NanoSignatures.verify( frontierHash, frontierContents.signature, destinationAddress, ); if (!valid) { throw Exception( "Frontier block signature is invalid! Potentially malicious block detected!"); } } // first get the account balance: late BigInt currentBalance; if (!openBlock) { currentBalance = BigInt.parse(frontierContents!.balance); } else { currentBalance = BigInt.zero; } final BigInt txAmount = BigInt.parse(amountRaw); final BigInt balanceAfterTx = currentBalance + txAmount; // link = send block hash: final String link = blockHash; // this "linkAsAccount" is meaningless: final String linkAsAccount = NanoDerivations.publicKeyToAddress(blockHash, currency: NanoBasedCurrency.NANO); // construct the receive block: Map receiveBlock = { "type": "state", "account": destinationAddress, "previous": frontier, "representative": representative, "balance": balanceAfterTx.toString(), "link": link, "link_as_account": linkAsAccount, }; // sign the receive block: final String hash = NanoSignatures.computeStateHash( NanoBasedCurrency.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(NanoDerivations.addressToPublicKey(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: getHeaders(), 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: getHeaders(), body: jsonEncode({ "action": "receivable", "account": destinationAddress, "count": "-1", "source": true, })); final receivableData = await jsonDecode(receivableResponse.body); if (receivableData["blocks"] == "" || receivableData["blocks"] == null) { return 0; } dynamic blocks; if (receivableData["blocks"] is List) { var listBlocks = receivableData["blocks"] as List; if (listBlocks.isEmpty) { return 0; } blocks = {for (var block in listBlocks) block['hash']: block}; } else { blocks = receivableData["blocks"] as Map; } blocks = blocks as Map; // confirm all receivable blocks: for (final blockHash in blocks.keys) { final block = blocks[blockHash]; final String amountRaw = block["amount"] as String; await receiveBlock( blockHash: blockHash, amountRaw: amountRaw, privateKey: privateKey, destinationAddress: destinationAddress, ); // a bit of a hack: await Future.delayed(const Duration(seconds: 2)); } return blocks.keys.length; } void stop() {} Future> fetchTransactions(String address) async { try { final response = await http.post(_node!.uri, headers: getHeaders(), body: jsonEncode({ "action": "account_history", "account": address, "count": "100", // "raw": true, })); final data = await jsonDecode(response.body); final transactions = data["history"] is List ? data["history"] as List : []; // 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)) .toList(); } catch (e) { print(e); return []; } } Future> getN2Reps() async { final response = await http.post( Uri.parse(N2_REPS_ENDPOINT), headers: CAKE_HEADERS, body: jsonEncode({"action": "reps"}), ); try { final List nodes = (json.decode(response.body) as List) .map((dynamic e) => N2Node.fromJson(e as Map)) .toList(); return nodes; } catch (error) { return []; } } Future getRepScore(String rep) async { final response = await http.post( Uri.parse(N2_REPS_ENDPOINT), headers: CAKE_HEADERS, body: jsonEncode({ "action": "rep_info", "account": rep, }), ); try { final N2Node node = N2Node.fromJson(json.decode(response.body) as Map); return node.score ?? 100; } catch (error) { return 100; } } }