Files
hash-wallet/cw_nano/lib/nano_client.dart
T
75f3fe735b Introduce Money class and refactor currency handling (#3157)
* feat: add `Money` class for precise currency handling and amount parsing

- Introduced `Money` class for handling money values with fixed-point precision.
- Added parsing and comparison methods to `CryptoCurrency` and `FiatCurrency` interfaces.
- Implemented `smartAmountSanitizer` for format normalization.

Includes extensive unit tests for validation.

* refactor: optimize Money class with new factories and string formatting improvements

* refactor: unify balance handling with `Money` refactor

* refactor: update `ElectrumBalance` and transaction logic to use `Money` for currency precision

* refactor: standardize balance handling with `Money` across `Monero`, `Wownero`, and `Zano` wallets

* refactor: migrate EVM balance and transaction handling to `Money` for consistent currency precision

* refactor: migrate Decred balance and transaction handling to `Money` for consistent currency precision

* refactor: migrate nano balance and transaction handling to `Money` for consistent currency precision

* refactor: migrate Solana balance and transaction logic to `Money` for precise currency handling

* refactor: migrate Tron balance and transaction handling to `Money` for consistent currency precision

* refactor: migrate Zcash balance and transaction handling to `Money` for consistent currency precision

* refactor: adjust lib to handle money

* refactor: remove legacy Decred amount formatting and migrate to `Money`

* refactor: migrate pending transaction amount and fee to `Money`

* refactor: migrate dEuro savings and transaction logic to `Money` for improved precision and consistency

* refactor: migrate Bitcoin and lightning transaction logic to `Money` for consistent currency precision

* refactor: migrate Wownero, EVM, Zano, and Zcash transaction logic to `Money` for consistent and precise currency handling

* refactor: standardize balance handling across wallets, migrate to `Money.zero` and refine `Balance` class for consistency

* refactor: migrate Solana transaction and wallet logic to `Money` for consistent and precise currency handling

* refactor: improve fiat balance and formatting for consistency on dashboard

* fix: fiat input mode switching and standardize crypto amount display logic

* fix: cw_evm generator

* refactor: migrate exchange amount handling to `Money` for improved precision and consistency

* test: remove redundant and commented-out tests, tidy test cases, and apply minor formatting adjustments

* fix using Money wrapper in BridgeViewModel and USDT0Service

* refactor: streamline `AmountConverter` and `LightningWallet`, add `cw_core` tests to GitHub workflow, and remove unused/deprecated logic

* refactor: enhance `changeReceiveAmount` with `isCanonical` support, improve null handling, and refine amount parsing logic

* minor fix

* try/catch the lightning log file error

* feat: add copy-to-clipboard functionality for transaction amounts, fix typo in trailing widget method name (#3219)

* Cw 1234 disallow sp and mweb addresses as refund for swaps (#3187)

* disallow SP and MWEB for swaps

* Simplify address selection in addressForExchange

Refactor addressForExchange method to simplify address selection logic.

* validate refund and receive address types

* localize address validation messages

* minor ui fix [skip ci]

* minor fix [skip ci]

* Revert "localize address validation messages"

* fix translations

---------

Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>

* refactor: replace manual amount sanitization with `sanitized()` extension across the codebase, add unit tests for `AmountSanitizer`

* fix: correct `sanitized()` test expectations for decimal handling in `amount_sanitizer_test.dart`

* refactor: fee display logic and append currency symbol in `send_confirm_sheet.dart`

* refactor: simplify `ZcashBalance` class by replacing custom fields with inherited properties, update references accordingly

* refactor: replace hardcoded USDT contract address with `DefaultTronTokens` lookup, improve maintainability

* refactor: replace `currency` with `inputAmount.currency` in Solana transaction signing methods to avoid inconsistency

* fix: handle null `fee` in `electrum_transaction_info.dart` to prevent potential runtime errors

* refactor: replace raw fee and amount handling with `Money` object for improved consistency and maintainability

* fix: limit fiat balance display to 2 decimal places in `balance_view_model.dart`

* refactor: replace raw `amount` and `fee` types with `Money` in Solana transaction methods for consistency and precision

* refactor: replace raw `amount` and `fee` handling with `Money` and improve balance calculation consistency across view models

* refactor: replace raw balance strings with `Money` and simplify `UnconfirmedBalanceModal` widget logic

* refactor: replace `print` with `printV` for better logging, add equality and hashCode overrides to `ERC20Currency` for object comparison

* feat: prepare deuro savings migration

* feat: add V1 savings migration support and improve formatting consistency in DEuro view model

* feat: add V1 savings withdrawal support and update associated UI components and logic

* refactor: replace `Image.asset` with `CakeImageWidget`, update balance string handling, and streamline address validation logic

* refactor: use `Money.tryParse` with fallback to `Money.zero` in `NanoBalance.fromRawString` for safer balance parsing

* refactor: replace `ERC20Currency` with `Erc20Token` for consistency and remove unused `evm_erc20_currency.dart` file

* refactor: add computed `hasDepositAmount` for deposit null-checks, optimize balance handling in `zano_wallet`, update Breez SDK to v0.14.0, and implement equality/hashCode for `Currency`

* refactor: rename `amountSats` to `amount` in `PrepareLnurlPayRequest` for consistency

* revert: downgrade Breez SDK to v0.11.0 in `pubspec.yaml` and update relevant references in `pubspec.lock`

* fix: use `cryptoAmount.copyWith` for currency consistency and replace `Image.asset` with `CakeImageWidget` in swap address modal

* fix: ensure currency consistency by using `cryptoAmount.copyWith` in Solana and EVM wallet transactions

* refactor: simplify CSV row generation logic by removing `_splitAmountCurrency` and streamlining amount/fee handling

* fix: LateInitializationError Field 'sdk' has not been initialized

* fix: use `Money.tryParse` for safer parsing of deposit and receive amounts

* chore: remove debug print statement

* refactor: update transaction processing to consistently use `Money` for amounts and fees

* refactor: improve currency and amount handling, simplify logic, and enhance code readability in Send and Swap pages

* refactor: integrate `DecimalInputFormatter` for consistent decimal input across Send and Swap pages, simplify input handling logic

* fix: "fiatMode" per output on sendPage

* refactor: make transaction_details_height applicable more readable

* fix: use correct base unit for deposit

* refactor: use Money in estimateFakeSendAllTxAmount method

* fix decred frozen balance
minor fixes

* fix decred frozen balance
minor fixes

* fix: automatically switch to lightning if lnurl or invoice detected

* fix: getBolt11Amount test cases

* fix: standardise crypto amount formatting and display

* fix: use base unit for Tron transaction fee parsing
* refactor: hardcode fractional digits to 8 in `AmountParsingProxy` display methods
* refactor: ensure consistent 8-decimal precision for fee formatting in `PendingTransaction`
* fix: use `AmountParsingProxy` for available balance display in exchange view model
* fix: improve amount formatting and layout in swap and send confirmation sheets
* chore: remove redundant decimal truncation in balance view model and send confirmation sheet

* fix: allow for editing text field if it is filled with "ALL"

* fix: allow for editing text field if it is filled with "ALL"

* fix: update currency detection logic for lightning mode

* avoid potential infinite loop [skip ci]

* fix: getDisplayCryptoAmount

* fix: update estimated fee calculation

* chore: simplify fee calculation logic [skip-ci]

* fix value display in hidden mode

* fix stale state in send confirm sheet

* fix decimals for mweb

* fix available balance display for sol/trx on swap

* restore proper hint text

* fix swap receive amount calculation for xno

---------

Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>
Co-authored-by: Serhii <17529954+serhii-bor@users.noreply.github.com>
Co-authored-by: Robert Malikowski <malikowskirobert@gmail.com>
2026-06-30 02:45:44 +03:00

565 lines
17 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:cw_core/amount/money.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/nano_account_info_response.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/utils/proxy_wrapper.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: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<String, String> 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<String, String> getHeaders(String host) {
final headers = Map<String, String>.from(CAKE_HEADERS);
if (host == "rpc.nano.to") {
headers["key"] = nano_secrets.nano2ApiKey;
}
if (host == "nano.nownodes.io") {
headers["api-key"] = nano_secrets.nanoNowNodesApiKey;
}
return headers;
}
Future<NanoBalance> getBalance(String address) async {
final response = await ProxyWrapper().post(
clearnetUri: _node!.uri,
headers: getHeaders(_node!.uri.host),
body: jsonEncode(
{
"action": "account_balance",
"account": address,
},
),
);
final data = jsonDecode(response.body) as Map<String, dynamic>;
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 currentBalance = data["balance"] as String;
final receivableBalance = data["receivable"] as String;
final cur = Money(BigInt.parse(currentBalance), CryptoCurrency.nano);
final rec = Money(BigInt.parse(receivableBalance), CryptoCurrency.nano);
return NanoBalance(currentBalance: cur, receivableBalance: rec);
}
Future<AccountInfoResponse?> getAccountInfo(String address, {bool throwOnError = false}) async {
try {
final response = await ProxyWrapper().post(
clearnetUri: _node!.uri,
headers: getHeaders(_node!.uri.host),
body: jsonEncode(
{
"action": "account_info",
"representative": "true",
"account": address,
},
),
);
final data = jsonDecode(response.body) as Map<String, dynamic>;
return AccountInfoResponse.fromJson(data);
} catch (e) {
printV("error while getting account info $e");
if (throwOnError) {
rethrow;
}
return null;
}
}
Future<BlockContentsResponse?> getBlockContents(String block) async {
try {
final response = await ProxyWrapper().post(
clearnetUri: _node!.uri,
headers: getHeaders(_node!.uri.host),
body: jsonEncode(
{
"action": "block_info",
"json_block": "true",
"hash": block,
},
),
);
final data = jsonDecode(response.body) as Map<String, dynamic>;
return BlockContentsResponse.fromJson(data["contents"] as Map<String, dynamic>);
} catch (e) {
printV("error while getting block info $e");
return null;
}
}
Future<String> 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<String, String> 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<String> requestWork(String hash) async {
final response = await ProxyWrapper().post(
clearnetUri: _powNode!.uri,
headers: getHeaders(_powNode!.uri.host),
body: json.encode(
{
"action": "work_generate",
"hash": hash,
},
),
);
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
if (decoded.containsKey("error")) {
throw Exception("Received error ${decoded["error"]}");
}
return decoded["work"] as String;
} else {
throw Exception("Received work error ${response.body}");
}
}
Future<String> send({
required String privateKey,
required String amountRaw,
required String destinationAddress,
}) async {
final Map<String, String> sendBlock = await constructSendBlock(
privateKey: privateKey,
amountRaw: amountRaw,
destinationAddress: destinationAddress,
);
return await processBlock(sendBlock, "send");
}
Future<String> processBlock(Map<String, String> block, String subtype) async {
final processBody = jsonEncode({
"action": "process",
"json_block": "true",
"subtype": subtype,
"block": block,
});
final processResponse = await ProxyWrapper().post(
clearnetUri: _node!.uri,
headers: getHeaders(_node!.uri.host),
body: processBody,
);
final Map<String, dynamic> decoded = jsonDecode(processResponse.body) as Map<String, dynamic>;
if (decoded.containsKey("error")) {
throw Exception("Received error ${decoded["error"]}");
}
// return the hash of the transaction:
return decoded["hash"].toString();
}
Future<Map<String, String>> 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.amount;
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<String, String> 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<void> 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<String, String> 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 ProxyWrapper().post(
clearnetUri: _node!.uri,
headers: getHeaders(_node!.uri.host),
body: processBody,
);
final Map<String, dynamic> decoded = json.decode(processResponse.body) as Map<String, dynamic>;
if (decoded.containsKey("error")) {
throw Exception("Received error ${decoded["error"]}");
}
}
// returns the number of blocks received:
Future<int> confirmAllReceivable({
required String destinationAddress,
required String privateKey,
}) async {
try {
final receivableResponse = await ProxyWrapper().post(
clearnetUri: _node!.uri,
headers: getHeaders(_node!.uri.host),
body: jsonEncode({
"action": "receivable",
"account": destinationAddress,
"count": "-1",
"source": true,
}),
);
final receivableData = jsonDecode(receivableResponse.body) as Map<String, dynamic>;
if (receivableData["blocks"] == "" || receivableData["blocks"] == null) {
return 0;
}
dynamic blocks;
if (receivableData["blocks"] is List<dynamic>) {
var listBlocks = receivableData["blocks"] as List<dynamic>;
if (listBlocks.isEmpty) {
return 0;
}
blocks = {for (var block in listBlocks) block['hash']: block};
} else {
blocks = receivableData["blocks"] as Map<String, dynamic>;
}
blocks = blocks as Map<String, dynamic>;
// 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<void>.delayed(const Duration(seconds: 2));
}
return blocks.keys.length;
} catch (_) {
// we failed to confirm all receivable blocks for w/e reason (PoW / node outage / etc)
return 0;
}
}
void stop() {}
Future<List<NanoTransactionModel>> fetchTransactions(String address) async {
try {
final response = await ProxyWrapper().post(
clearnetUri: _node!.uri,
headers: getHeaders(_node!.uri.host),
body: jsonEncode({
"action": "account_history",
"account": address,
"count": "100",
// "raw": true,
}),
);
final data = jsonDecode(response.body) as Map<String, dynamic>;
final transactions = data["history"] is List ? data["history"] as List<dynamic> : [];
// 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<NanoTransactionModel>((transaction) => NanoTransactionModel.fromJson(transaction))
.toList();
} catch (e) {
printV("error fetching transactions: $e");
rethrow;
}
}
Future<List<N2Node>> getN2Reps() async {
final uri = Uri.parse(N2_REPS_ENDPOINT);
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: getHeaders(uri.host),
body: jsonEncode({"action": "reps"}),
);
try {
final List<N2Node> nodes = (jsonDecode(response.body) as List<dynamic>)
.map((dynamic e) => N2Node.fromJson(e as Map<String, dynamic>))
.toList();
return nodes;
} catch (error) {
return [];
}
}
Future<int> getRepScore(String rep) async {
final uri = Uri.parse(N2_REPS_ENDPOINT);
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: getHeaders(uri.host),
body: jsonEncode({
"action": "rep_info",
"account": rep,
}),
);
try {
final N2Node node = N2Node.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
return node.score ?? 100;
} catch (error) {
return 100;
}
}
}