| 1 | import 'dart:async'; |
| 2 | import 'dart:convert'; |
| 3 | |
| 4 | import 'package:cw_core/amount/money.dart'; |
| 5 | import 'package:cw_core/crypto_currency.dart'; |
| 6 | import 'package:cw_core/nano_account_info_response.dart'; |
| 7 | import 'package:cw_core/utils/print_verbose.dart'; |
| 8 | import 'package:cw_core/utils/proxy_wrapper.dart'; |
| 9 | import 'package:cw_nano/nano_block_info_response.dart'; |
| 10 | import 'package:cw_core/n2_node.dart'; |
| 11 | import 'package:cw_nano/nano_balance.dart'; |
| 12 | import 'package:cw_nano/nano_transaction_model.dart'; |
| 13 | import 'package:cw_core/node.dart'; |
| 14 | import 'package:nanoutil/nanoutil.dart'; |
| 15 | import 'package:shared_preferences/shared_preferences.dart'; |
| 16 | import 'package:cw_nano/.secrets.g.dart' as nano_secrets; |
| 17 | |
| 18 | class NanoClient { |
| 19 | static const Map<String, String> CAKE_HEADERS = { |
| 20 | "Content-Type": "application/json", |
| 21 | "nano-app": "cake-wallet" |
| 22 | }; |
| 23 | |
| 24 | static const String N2_REPS_ENDPOINT = "https://rpc.nano.to"; |
| 25 | |
| 26 | NanoClient() { |
| 27 | SharedPreferences.getInstance().then((value) => prefs = value); |
| 28 | } |
| 29 | |
| 30 | late SharedPreferences prefs; |
| 31 | Node? _node; |
| 32 | Node? _powNode; |
| 33 | static const String _defaultDefaultRepresentative = |
| 34 | "nano_38713x95zyjsqzx6nm1dsom1jmm668owkeb9913ax6nfgj15az3nu8xkx579"; |
| 35 | |
| 36 | String getRepFromPrefs() { |
| 37 | // from preferences_key.dart "defaultNanoRep" key: |
| 38 | return prefs.getString("default_nano_representative") ?? _defaultDefaultRepresentative; |
| 39 | } |
| 40 | |
| 41 | bool connect(Node node) { |
| 42 | try { |
| 43 | _node = node; |
| 44 | return true; |
| 45 | } catch (e) { |
| 46 | return false; |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | bool connectPow(Node node) { |
| 51 | try { |
| 52 | _powNode = node; |
| 53 | return true; |
| 54 | } catch (e) { |
| 55 | return false; |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | Map<String, String> getHeaders(String host) { |
| 60 | final headers = Map<String, String>.from(CAKE_HEADERS); |
| 61 | if (host == "rpc.nano.to") { |
| 62 | headers["key"] = nano_secrets.nano2ApiKey; |
| 63 | } |
| 64 | if (host == "nano.nownodes.io") { |
| 65 | headers["api-key"] = nano_secrets.nanoNowNodesApiKey; |
| 66 | } |
| 67 | return headers; |
| 68 | } |
| 69 | |
| 70 | Future<NanoBalance> getBalance(String address) async { |
| 71 | final response = await ProxyWrapper().post( |
| 72 | clearnetUri: _node!.uri, |
| 73 | headers: getHeaders(_node!.uri.host), |
| 74 | body: jsonEncode( |
| 75 | { |
| 76 | "action": "account_balance", |
| 77 | "account": address, |
| 78 | }, |
| 79 | ), |
| 80 | ); |
| 81 | |
| 82 | final data = jsonDecode(response.body) as Map<String, dynamic>; |
| 83 | if (response.statusCode != 200 || |
| 84 | data["error"] != null || |
| 85 | data["balance"] == null || |
| 86 | data["receivable"] == null) { |
| 87 | throw Exception( |
| 88 | "Error while trying to get balance! ${data["error"] != null ? data["error"] : ""}"); |
| 89 | } |
| 90 | final currentBalance = data["balance"] as String; |
| 91 | final receivableBalance = data["receivable"] as String; |
| 92 | final cur = Money(BigInt.parse(currentBalance), CryptoCurrency.nano); |
| 93 | final rec = Money(BigInt.parse(receivableBalance), CryptoCurrency.nano); |
| 94 | return NanoBalance(currentBalance: cur, receivableBalance: rec); |
| 95 | } |
| 96 | |
| 97 | Future<AccountInfoResponse?> getAccountInfo(String address, {bool throwOnError = false}) async { |
| 98 | try { |
| 99 | final response = await ProxyWrapper().post( |
| 100 | clearnetUri: _node!.uri, |
| 101 | headers: getHeaders(_node!.uri.host), |
| 102 | body: jsonEncode( |
| 103 | { |
| 104 | "action": "account_info", |
| 105 | "representative": "true", |
| 106 | "account": address, |
| 107 | }, |
| 108 | ), |
| 109 | ); |
| 110 | |
| 111 | final data = jsonDecode(response.body) as Map<String, dynamic>; |
| 112 | return AccountInfoResponse.fromJson(data); |
| 113 | } catch (e) { |
| 114 | printV("error while getting account info $e"); |
| 115 | if (throwOnError) { |
| 116 | rethrow; |
| 117 | } |
| 118 | return null; |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | Future<BlockContentsResponse?> getBlockContents(String block) async { |
| 123 | try { |
| 124 | final response = await ProxyWrapper().post( |
| 125 | clearnetUri: _node!.uri, |
| 126 | headers: getHeaders(_node!.uri.host), |
| 127 | body: jsonEncode( |
| 128 | { |
| 129 | "action": "block_info", |
| 130 | "json_block": "true", |
| 131 | "hash": block, |
| 132 | }, |
| 133 | ), |
| 134 | ); |
| 135 | |
| 136 | final data = jsonDecode(response.body) as Map<String, dynamic>; |
| 137 | return BlockContentsResponse.fromJson(data["contents"] as Map<String, dynamic>); |
| 138 | } catch (e) { |
| 139 | printV("error while getting block info $e"); |
| 140 | return null; |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | Future<String> changeRep({ |
| 145 | required String privateKey, |
| 146 | required String repAddress, |
| 147 | required String ourAddress, |
| 148 | }) async { |
| 149 | AccountInfoResponse? accountInfo = await getAccountInfo(ourAddress); |
| 150 | |
| 151 | if (accountInfo == null) { |
| 152 | throw Exception( |
| 153 | "error while getting account info, you can't change the rep of an unopened account"); |
| 154 | } |
| 155 | |
| 156 | // construct the change block: |
| 157 | Map<String, String> changeBlock = { |
| 158 | "type": "state", |
| 159 | "account": ourAddress, |
| 160 | "previous": accountInfo.frontier, |
| 161 | "representative": repAddress, |
| 162 | "balance": accountInfo.balance, |
| 163 | "link": "0000000000000000000000000000000000000000000000000000000000000000", |
| 164 | "link_as_account": "nano_1111111111111111111111111111111111111111111111111111hifc8npp", |
| 165 | }; |
| 166 | |
| 167 | // sign the change block: |
| 168 | final String hash = NanoSignatures.computeStateHash( |
| 169 | NanoBasedCurrency.NANO, |
| 170 | changeBlock["account"]!, |
| 171 | changeBlock["previous"]!, |
| 172 | changeBlock["representative"]!, |
| 173 | BigInt.parse(changeBlock["balance"]!), |
| 174 | changeBlock["link"]!, |
| 175 | ); |
| 176 | final String signature = NanoSignatures.signBlock(hash, privateKey); |
| 177 | |
| 178 | // get PoW for the send block: |
| 179 | final String work = await requestWork(accountInfo.frontier); |
| 180 | |
| 181 | changeBlock["signature"] = signature; |
| 182 | changeBlock["work"] = work; |
| 183 | |
| 184 | try { |
| 185 | return await processBlock(changeBlock, "change"); |
| 186 | } catch (e) { |
| 187 | throw Exception("error while changing representative: $e"); |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | Future<String> requestWork(String hash) async { |
| 192 | final response = await ProxyWrapper().post( |
| 193 | clearnetUri: _powNode!.uri, |
| 194 | headers: getHeaders(_powNode!.uri.host), |
| 195 | body: json.encode( |
| 196 | { |
| 197 | "action": "work_generate", |
| 198 | "hash": hash, |
| 199 | }, |
| 200 | ), |
| 201 | ); |
| 202 | |
| 203 | if (response.statusCode == 200) { |
| 204 | final decoded = jsonDecode(response.body) as Map<String, dynamic>; |
| 205 | if (decoded.containsKey("error")) { |
| 206 | throw Exception("Received error ${decoded["error"]}"); |
| 207 | } |
| 208 | return decoded["work"] as String; |
| 209 | } else { |
| 210 | throw Exception("Received work error ${response.body}"); |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | Future<String> send({ |
| 215 | required String privateKey, |
| 216 | required String amountRaw, |
| 217 | required String destinationAddress, |
| 218 | }) async { |
| 219 | final Map<String, String> sendBlock = await constructSendBlock( |
| 220 | privateKey: privateKey, |
| 221 | amountRaw: amountRaw, |
| 222 | destinationAddress: destinationAddress, |
| 223 | ); |
| 224 | |
| 225 | return await processBlock(sendBlock, "send"); |
| 226 | } |
| 227 | |
| 228 | Future<String> processBlock(Map<String, String> block, String subtype) async { |
| 229 | final processBody = jsonEncode({ |
| 230 | "action": "process", |
| 231 | "json_block": "true", |
| 232 | "subtype": subtype, |
| 233 | "block": block, |
| 234 | }); |
| 235 | |
| 236 | final processResponse = await ProxyWrapper().post( |
| 237 | clearnetUri: _node!.uri, |
| 238 | headers: getHeaders(_node!.uri.host), |
| 239 | body: processBody, |
| 240 | ); |
| 241 | |
| 242 | final Map<String, dynamic> decoded = jsonDecode(processResponse.body) as Map<String, dynamic>; |
| 243 | if (decoded.containsKey("error")) { |
| 244 | throw Exception("Received error ${decoded["error"]}"); |
| 245 | } |
| 246 | |
| 247 | // return the hash of the transaction: |
| 248 | return decoded["hash"].toString(); |
| 249 | } |
| 250 | |
| 251 | Future<Map<String, String>> constructSendBlock({ |
| 252 | required String privateKey, |
| 253 | required String amountRaw, |
| 254 | required String destinationAddress, |
| 255 | BigInt? balanceAfterTx, |
| 256 | String? previousHash, |
| 257 | }) async { |
| 258 | // our address: |
| 259 | final String publicAddress = NanoDerivations.privateKeyToAddress(privateKey); |
| 260 | |
| 261 | // first get the current account balance: |
| 262 | if (balanceAfterTx == null) { |
| 263 | final BigInt currentBalance = (await getBalance(publicAddress)).currentBalance.amount; |
| 264 | final BigInt txAmount = BigInt.parse(amountRaw); |
| 265 | balanceAfterTx = currentBalance - txAmount; |
| 266 | } |
| 267 | |
| 268 | // get the account info (we need the frontier and representative): |
| 269 | AccountInfoResponse? infoResponse = await getAccountInfo(publicAddress); |
| 270 | if (infoResponse == null) { |
| 271 | throw Exception( |
| 272 | "error while getting account info! (we probably don't have an open account yet)"); |
| 273 | } |
| 274 | |
| 275 | String frontier = infoResponse.frontier; |
| 276 | // override if provided: |
| 277 | if (previousHash != null) { |
| 278 | frontier = previousHash; |
| 279 | } |
| 280 | final String representative = infoResponse.representative; |
| 281 | // link = destination address: |
| 282 | final String link = NanoDerivations.addressToPublicKey(destinationAddress); |
| 283 | final String linkAsAccount = destinationAddress; |
| 284 | |
| 285 | // construct the send block: |
| 286 | Map<String, String> sendBlock = { |
| 287 | "type": "state", |
| 288 | "account": publicAddress, |
| 289 | "previous": frontier, |
| 290 | "representative": representative, |
| 291 | "balance": balanceAfterTx.toString(), |
| 292 | "link": link, |
| 293 | }; |
| 294 | |
| 295 | // sign the send block: |
| 296 | final String hash = NanoSignatures.computeStateHash( |
| 297 | NanoBasedCurrency.NANO, |
| 298 | sendBlock["account"]!, |
| 299 | sendBlock["previous"]!, |
| 300 | sendBlock["representative"]!, |
| 301 | BigInt.parse(sendBlock["balance"]!), |
| 302 | sendBlock["link"]!, |
| 303 | ); |
| 304 | final String signature = NanoSignatures.signBlock(hash, privateKey); |
| 305 | |
| 306 | // get PoW for the send block: |
| 307 | final String work = await requestWork(frontier); |
| 308 | |
| 309 | sendBlock["link_as_account"] = linkAsAccount; |
| 310 | sendBlock["signature"] = signature; |
| 311 | sendBlock["work"] = work; |
| 312 | |
| 313 | // ready to post send block: |
| 314 | return sendBlock; |
| 315 | } |
| 316 | |
| 317 | Future<void> receiveBlock({ |
| 318 | required String blockHash, |
| 319 | required String amountRaw, |
| 320 | required String destinationAddress, |
| 321 | required String privateKey, |
| 322 | }) async { |
| 323 | bool openBlock = false; |
| 324 | |
| 325 | // first check if the account is open: |
| 326 | // get the account info (we need the frontier and representative): |
| 327 | AccountInfoResponse? infoData = await getAccountInfo(destinationAddress); |
| 328 | String? frontier; |
| 329 | String? representative; |
| 330 | |
| 331 | if (infoData == null) { |
| 332 | // account is not open yet, we need to create an open block: |
| 333 | openBlock = true; |
| 334 | // we don't have a representative set yet: |
| 335 | representative = await getRepFromPrefs(); |
| 336 | // we don't have a frontier yet: |
| 337 | frontier = "0000000000000000000000000000000000000000000000000000000000000000"; |
| 338 | } else { |
| 339 | frontier = infoData.frontier; |
| 340 | representative = infoData.representative; |
| 341 | } |
| 342 | |
| 343 | if ((BigInt.tryParse(amountRaw) ?? BigInt.zero) <= BigInt.zero) { |
| 344 | throw Exception("amountRaw must be greater than zero"); |
| 345 | } |
| 346 | |
| 347 | BlockContentsResponse? frontierContents; |
| 348 | |
| 349 | if (!openBlock) { |
| 350 | // get the block info of the frontier block: |
| 351 | frontierContents = await getBlockContents(frontier); |
| 352 | |
| 353 | if (frontierContents == null) { |
| 354 | throw Exception("error while getting frontier block info"); |
| 355 | } |
| 356 | |
| 357 | final String frontierHash = NanoSignatures.computeStateHash( |
| 358 | NanoBasedCurrency.NANO, |
| 359 | frontierContents.account, |
| 360 | frontierContents.previous, |
| 361 | frontierContents.representative, |
| 362 | BigInt.parse(frontierContents.balance), |
| 363 | frontierContents.link, |
| 364 | ); |
| 365 | |
| 366 | bool valid = await NanoSignatures.verify( |
| 367 | frontierHash, |
| 368 | frontierContents.signature, |
| 369 | destinationAddress, |
| 370 | ); |
| 371 | |
| 372 | if (!valid) { |
| 373 | throw Exception( |
| 374 | "Frontier block signature is invalid! Potentially malicious block detected!"); |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | // first get the account balance: |
| 379 | late BigInt currentBalance; |
| 380 | if (!openBlock) { |
| 381 | currentBalance = BigInt.parse(frontierContents!.balance); |
| 382 | } else { |
| 383 | currentBalance = BigInt.zero; |
| 384 | } |
| 385 | final BigInt txAmount = BigInt.parse(amountRaw); |
| 386 | final BigInt balanceAfterTx = currentBalance + txAmount; |
| 387 | |
| 388 | // link = send block hash: |
| 389 | final String link = blockHash; |
| 390 | // this "linkAsAccount" is meaningless: |
| 391 | final String linkAsAccount = |
| 392 | NanoDerivations.publicKeyToAddress(blockHash, currency: NanoBasedCurrency.NANO); |
| 393 | |
| 394 | // construct the receive block: |
| 395 | Map<String, String> receiveBlock = { |
| 396 | "type": "state", |
| 397 | "account": destinationAddress, |
| 398 | "previous": frontier, |
| 399 | "representative": representative, |
| 400 | "balance": balanceAfterTx.toString(), |
| 401 | "link": link, |
| 402 | "link_as_account": linkAsAccount, |
| 403 | }; |
| 404 | |
| 405 | // sign the receive block: |
| 406 | final String hash = NanoSignatures.computeStateHash( |
| 407 | NanoBasedCurrency.NANO, |
| 408 | receiveBlock["account"]!, |
| 409 | receiveBlock["previous"]!, |
| 410 | receiveBlock["representative"]!, |
| 411 | BigInt.parse(receiveBlock["balance"]!), |
| 412 | receiveBlock["link"]!, |
| 413 | ); |
| 414 | final String signature = NanoSignatures.signBlock(hash, privateKey); |
| 415 | |
| 416 | // get PoW for the receive block: |
| 417 | String? work; |
| 418 | if (openBlock) { |
| 419 | work = await requestWork(NanoDerivations.addressToPublicKey(destinationAddress)); |
| 420 | } else { |
| 421 | work = await requestWork(frontier); |
| 422 | } |
| 423 | receiveBlock["link_as_account"] = linkAsAccount; |
| 424 | receiveBlock["signature"] = signature; |
| 425 | receiveBlock["work"] = work; |
| 426 | |
| 427 | // process the receive block: |
| 428 | |
| 429 | final processBody = jsonEncode({ |
| 430 | "action": "process", |
| 431 | "json_block": "true", |
| 432 | "subtype": "receive", |
| 433 | "block": receiveBlock, |
| 434 | }); |
| 435 | final processResponse = await ProxyWrapper().post( |
| 436 | clearnetUri: _node!.uri, |
| 437 | headers: getHeaders(_node!.uri.host), |
| 438 | body: processBody, |
| 439 | ); |
| 440 | final Map<String, dynamic> decoded = json.decode(processResponse.body) as Map<String, dynamic>; |
| 441 | if (decoded.containsKey("error")) { |
| 442 | throw Exception("Received error ${decoded["error"]}"); |
| 443 | } |
| 444 | } |
| 445 | |
| 446 | // returns the number of blocks received: |
| 447 | Future<int> confirmAllReceivable({ |
| 448 | required String destinationAddress, |
| 449 | required String privateKey, |
| 450 | }) async { |
| 451 | try { |
| 452 | final receivableResponse = await ProxyWrapper().post( |
| 453 | clearnetUri: _node!.uri, |
| 454 | headers: getHeaders(_node!.uri.host), |
| 455 | body: jsonEncode({ |
| 456 | "action": "receivable", |
| 457 | "account": destinationAddress, |
| 458 | "count": "-1", |
| 459 | "source": true, |
| 460 | }), |
| 461 | ); |
| 462 | final receivableData = jsonDecode(receivableResponse.body) as Map<String, dynamic>; |
| 463 | if (receivableData["blocks"] == "" || receivableData["blocks"] == null) { |
| 464 | return 0; |
| 465 | } |
| 466 | |
| 467 | dynamic blocks; |
| 468 | if (receivableData["blocks"] is List<dynamic>) { |
| 469 | var listBlocks = receivableData["blocks"] as List<dynamic>; |
| 470 | if (listBlocks.isEmpty) { |
| 471 | return 0; |
| 472 | } |
| 473 | blocks = {for (var block in listBlocks) block['hash']: block}; |
| 474 | } else { |
| 475 | blocks = receivableData["blocks"] as Map<String, dynamic>; |
| 476 | } |
| 477 | |
| 478 | blocks = blocks as Map<String, dynamic>; |
| 479 | // confirm all receivable blocks: |
| 480 | for (final blockHash in blocks.keys) { |
| 481 | final block = blocks[blockHash]; |
| 482 | final String amountRaw = block["amount"] as String; |
| 483 | await receiveBlock( |
| 484 | blockHash: blockHash, |
| 485 | amountRaw: amountRaw, |
| 486 | privateKey: privateKey, |
| 487 | destinationAddress: destinationAddress, |
| 488 | ); |
| 489 | // a bit of a hack: |
| 490 | await Future<void>.delayed(const Duration(seconds: 2)); |
| 491 | } |
| 492 | return blocks.keys.length; |
| 493 | } catch (_) { |
| 494 | // we failed to confirm all receivable blocks for w/e reason (PoW / node outage / etc) |
| 495 | return 0; |
| 496 | } |
| 497 | } |
| 498 | |
| 499 | void stop() {} |
| 500 | |
| 501 | Future<List<NanoTransactionModel>> fetchTransactions(String address) async { |
| 502 | try { |
| 503 | final response = await ProxyWrapper().post( |
| 504 | clearnetUri: _node!.uri, |
| 505 | headers: getHeaders(_node!.uri.host), |
| 506 | body: jsonEncode({ |
| 507 | "action": "account_history", |
| 508 | "account": address, |
| 509 | "count": "100", |
| 510 | // "raw": true, |
| 511 | }), |
| 512 | ); |
| 513 | |
| 514 | final data = jsonDecode(response.body) as Map<String, dynamic>; |
| 515 | final transactions = data["history"] is List ? data["history"] as List<dynamic> : []; |
| 516 | |
| 517 | // Map the transactions list to NanoTransactionModel using the factory |
| 518 | // reversed so that the DateTime is correct when local_timestamp is absent |
| 519 | return transactions.reversed |
| 520 | .map<NanoTransactionModel>((transaction) => NanoTransactionModel.fromJson(transaction)) |
| 521 | .toList(); |
| 522 | } catch (e) { |
| 523 | printV("error fetching transactions: $e"); |
| 524 | rethrow; |
| 525 | } |
| 526 | } |
| 527 | |
| 528 | Future<List<N2Node>> getN2Reps() async { |
| 529 | final uri = Uri.parse(N2_REPS_ENDPOINT); |
| 530 | final response = await ProxyWrapper().post( |
| 531 | clearnetUri: uri, |
| 532 | headers: getHeaders(uri.host), |
| 533 | body: jsonEncode({"action": "reps"}), |
| 534 | ); |
| 535 | try { |
| 536 | final List<N2Node> nodes = (jsonDecode(response.body) as List<dynamic>) |
| 537 | .map((dynamic e) => N2Node.fromJson(e as Map<String, dynamic>)) |
| 538 | .toList(); |
| 539 | return nodes; |
| 540 | } catch (error) { |
| 541 | return []; |
| 542 | } |
| 543 | } |
| 544 | |
| 545 | Future<int> getRepScore(String rep) async { |
| 546 | final uri = Uri.parse(N2_REPS_ENDPOINT); |
| 547 | final response = await ProxyWrapper().post( |
| 548 | clearnetUri: uri, |
| 549 | headers: getHeaders(uri.host), |
| 550 | body: jsonEncode({ |
| 551 | "action": "rep_info", |
| 552 | "account": rep, |
| 553 | }), |
| 554 | ); |
| 555 | try { |
| 556 | final N2Node node = N2Node.fromJson(jsonDecode(response.body) as Map<String, dynamic>); |
| 557 | return node.score ?? 100; |
| 558 | } catch (error) { |
| 559 | return 100; |
| 560 | } |
| 561 | } |
| 562 | } |