stack_wallet/lib/wallets/wallet/impl/epiccash_wallet.dart
2024-05-03 14:43:02 -06:00

1176 lines
38 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:decimal/decimal.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_libepiccash/lib.dart' as epiccash;
import 'package:flutter_libepiccash/models/transaction.dart' as epic_models;
import 'package:isar/isar.dart';
import 'package:mutex/mutex.dart';
import 'package:stack_wallet_backup/generate_password.dart';
import 'package:stackwallet/models/balance.dart';
import 'package:stackwallet/models/epicbox_config_model.dart';
import 'package:stackwallet/models/isar/models/blockchain_data/address.dart';
import 'package:stackwallet/models/isar/models/blockchain_data/transaction.dart';
import 'package:stackwallet/models/isar/models/blockchain_data/v2/input_v2.dart';
import 'package:stackwallet/models/isar/models/blockchain_data/v2/output_v2.dart';
import 'package:stackwallet/models/isar/models/blockchain_data/v2/transaction_v2.dart';
import 'package:stackwallet/models/node_model.dart';
import 'package:stackwallet/models/paymint/fee_object_model.dart';
import 'package:stackwallet/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart';
import 'package:stackwallet/services/event_bus/events/global/blocks_remaining_event.dart';
import 'package:stackwallet/services/event_bus/events/global/node_connection_status_changed_event.dart';
import 'package:stackwallet/services/event_bus/events/global/refresh_percent_changed_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/utilities/amount/amount.dart';
import 'package:stackwallet/utilities/default_epicboxes.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/stack_file_system.dart';
import 'package:stackwallet/utilities/test_epic_box_connection.dart';
import 'package:stackwallet/wallets/crypto_currency/coins/epiccash.dart';
import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart';
import 'package:stackwallet/wallets/models/tx_data.dart';
import 'package:stackwallet/wallets/wallet/intermediate/bip39_wallet.dart';
import 'package:stackwallet/wallets/wallet/supporting/epiccash_wallet_info_extension.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
//
// refactor of https://github.com/cypherstack/stack_wallet/blob/1d9fb4cd069f22492ece690ac788e05b8f8b1209/lib/services/coins/epiccash/epiccash_wallet.dart
//
class EpiccashWallet extends Bip39Wallet {
EpiccashWallet(CryptoCurrencyNetwork network) : super(Epiccash(network));
final syncMutex = Mutex();
NodeModel? _epicNode;
Timer? timer;
double highestPercent = 0;
Future<double> get getSyncPercent async {
final int lastScannedBlock = info.epicData?.lastScannedBlock ?? 0;
final _chainHeight = await chainHeight;
final double restorePercent = lastScannedBlock / _chainHeight;
GlobalEventBus.instance
.fire(RefreshPercentChangedEvent(highestPercent, walletId));
if (restorePercent > highestPercent) {
highestPercent = restorePercent;
}
final int blocksRemaining = _chainHeight - lastScannedBlock;
GlobalEventBus.instance
.fire(BlocksRemainingEvent(blocksRemaining, walletId));
return restorePercent < 0 ? 0.0 : restorePercent;
}
Future<void> updateEpicboxConfig(String host, int port) async {
final String stringConfig = jsonEncode({
"epicbox_domain": host,
"epicbox_port": port,
"epicbox_protocol_unsecure": false,
"epicbox_address_index": 0,
});
await secureStorageInterface.write(
key: '${walletId}_epicboxConfig',
value: stringConfig,
);
// TODO: refresh anything that needs to be refreshed/updated due to epicbox info changed
}
/// returns an empty String on success, error message on failure
Future<String> cancelPendingTransactionAndPost(String txSlateId) async {
try {
final String wallet = (await secureStorageInterface.read(
key: '${walletId}_wallet',
))!;
final result = await epiccash.LibEpiccash.cancelTransaction(
wallet: wallet,
transactionId: txSlateId,
);
Logging.instance.log(
"cancel $txSlateId result: $result",
level: LogLevel.Info,
);
return result;
} catch (e, s) {
Logging.instance.log("$e, $s", level: LogLevel.Error);
return e.toString();
}
}
Future<EpicBoxConfigModel> getEpicBoxConfig() async {
final EpicBoxConfigModel _epicBoxConfig = EpicBoxConfigModel.fromServer(
DefaultEpicBoxes.defaultEpicBoxServer,
);
//Get the default Epicbox server and check if it's conected
// bool isEpicboxConnected = await _testEpicboxServer(
// DefaultEpicBoxes.defaultEpicBoxServer.host, DefaultEpicBoxes.defaultEpicBoxServer.port ?? 443);
// if (isEpicboxConnected) {
//Use default server for as Epicbox config
// }
// else {
// //Use Europe config
// _epicBoxConfig = EpicBoxConfigModel.fromServer(DefaultEpicBoxes.europe);
// }
// // example of selecting another random server from the default list
// // alternative servers: copy list of all default EB servers but remove the default default
// // List<EpicBoxServerModel> alternativeServers = DefaultEpicBoxes.all;
// // alternativeServers.removeWhere((opt) => opt.name == DefaultEpicBoxes.defaultEpicBoxServer.name);
// // alternativeServers.shuffle(); // randomize which server is used
// // _epicBoxConfig = EpicBoxConfigModel.fromServer(alternativeServers.first);
//
// // TODO test this connection before returning it
// }
return _epicBoxConfig;
}
// ================= Private =================================================
Future<String> _getConfig() async {
if (_epicNode == null) {
await updateNode();
}
final NodeModel node = _epicNode!;
final String nodeAddress = node.host;
final int port = node.port;
final uri = Uri.parse(nodeAddress).replace(port: port);
final String nodeApiAddress = uri.toString();
final walletDir = await _currentWalletDirPath();
final Map<String, dynamic> config = {};
config["wallet_dir"] = walletDir;
config["check_node_api_http_addr"] = nodeApiAddress;
config["chain"] = "mainnet";
config["account"] = "default";
config["api_listen_port"] = port;
config["api_listen_interface"] =
nodeApiAddress.replaceFirst(uri.scheme, "");
final String stringConfig = jsonEncode(config);
return stringConfig;
}
Future<String> _currentWalletDirPath() async {
final Directory appDir = await StackFileSystem.applicationRootDirectory();
final path = "${appDir.path}/epiccash";
final String name = walletId.trim();
return '$path/$name';
}
Future<int> _nativeFee(
int satoshiAmount, {
bool ifErrorEstimateFee = false,
}) async {
final wallet = await secureStorageInterface.read(key: '${walletId}_wallet');
try {
final available = info.cachedBalance.spendable.raw.toInt();
final transactionFees = await epiccash.LibEpiccash.getTransactionFees(
wallet: wallet!,
amount: satoshiAmount,
minimumConfirmations: cryptoCurrency.minConfirms,
available: available,
);
int realFee = 0;
try {
realFee =
(Decimal.parse(transactionFees.fee.toString())).toBigInt().toInt();
} catch (e, s) {
//todo: come back to this
debugPrint("$e $s");
}
return realFee;
} catch (e, s) {
Logging.instance.log("Error getting fees $e - $s", level: LogLevel.Error);
rethrow;
}
}
Future<void> _startSync() async {
Logging.instance.log("request start sync", level: LogLevel.Info);
final wallet = await secureStorageInterface.read(key: '${walletId}_wallet');
const int refreshFromNode = 1;
if (!syncMutex.isLocked) {
await syncMutex.protect(() async {
// How does getWalletBalances start syncing????
await epiccash.LibEpiccash.getWalletBalances(
wallet: wallet!,
refreshFromNode: refreshFromNode,
minimumConfirmations: 10,
);
});
} else {
Logging.instance.log("request start sync denied", level: LogLevel.Info);
}
}
Future<
({
double awaitingFinalization,
double pending,
double spendable,
double total
})> _allWalletBalances() async {
final wallet = await secureStorageInterface.read(key: '${walletId}_wallet');
const refreshFromNode = 0;
return await epiccash.LibEpiccash.getWalletBalances(
wallet: wallet!,
refreshFromNode: refreshFromNode,
minimumConfirmations: cryptoCurrency.minConfirms,
);
}
Future<bool> _testEpicboxServer(EpicBoxConfigModel epicboxConfig) async {
final host = epicboxConfig.host;
final port = epicboxConfig.port ?? 443;
WebSocketChannel? channel;
try {
final uri = Uri.parse('wss://$host:$port');
channel = WebSocketChannel.connect(
uri,
);
await channel.ready;
final response = await channel.stream.first.timeout(
const Duration(seconds: 2),
);
return response is String && response.contains("Challenge");
} catch (_) {
Logging.instance.log(
"_testEpicBoxConnection failed on \"$host:$port\"",
level: LogLevel.Info,
);
return false;
} finally {
await channel?.sink.close();
}
}
Future<bool> _putSendToAddresses(
({String slateId, String commitId}) slateData,
Map<String, String> txAddressInfo,
) async {
try {
final slatesToCommits = info.epicData?.slatesToCommits ?? {};
final from = txAddressInfo['from'];
final to = txAddressInfo['to'];
slatesToCommits[slateData.slateId] = {
"commitId": slateData.commitId,
"from": from,
"to": to,
};
await info.updateExtraEpiccashWalletInfo(
epicData: info.epicData!.copyWith(
slatesToCommits: slatesToCommits,
),
isar: mainDB.isar,
);
return true;
} catch (e, s) {
Logging.instance
.log("ERROR STORING ADDRESS $e $s", level: LogLevel.Error);
return false;
}
}
Future<int> _getCurrentIndex() async {
try {
final int receivingIndex = info.epicData!.receivingIndex;
// TODO: go through pendingarray and processed array and choose the index
// of the last one that has not been processed, or the index after the one most recently processed;
return receivingIndex;
} catch (e, s) {
Logging.instance.log("$e $s", level: LogLevel.Error);
return 0;
}
}
Future<Address> _generateAndStoreReceivingAddressForIndex(
int index,
) async {
Address? address = await getCurrentReceivingAddress();
if (address != null) {
final splitted = address.value.split('@');
//Check if the address is the same as the current epicbox domain
//Since we're only using one epicbpox now this doesn't apply but will be
// useful in the future
final epicboxConfig = await getEpicBoxConfig();
if (splitted[1] != epicboxConfig.host) {
//Update the address
address = await thisWalletAddress(index, epicboxConfig);
}
} else {
final epicboxConfig = await getEpicBoxConfig();
address = await thisWalletAddress(index, epicboxConfig);
}
if (info.cachedReceivingAddress != address.value) {
await info.updateReceivingAddress(
newAddress: address.value,
isar: mainDB.isar,
);
}
return address;
}
Future<Address> thisWalletAddress(
int index,
EpicBoxConfigModel epicboxConfig,
) async {
final wallet = await secureStorageInterface.read(key: '${walletId}_wallet');
final walletAddress = await epiccash.LibEpiccash.getAddressInfo(
wallet: wallet!,
index: index,
epicboxConfig: epicboxConfig.toString(),
);
Logging.instance.log(
"WALLET_ADDRESS_IS $walletAddress",
level: LogLevel.Info,
);
final address = Address(
walletId: walletId,
value: walletAddress,
derivationIndex: index,
derivationPath: null,
type: AddressType.mimbleWimble,
subType: AddressSubType.receiving,
publicKey: [], // ??
);
await mainDB.updateOrPutAddresses([address]);
return address;
}
Future<void> _startScans() async {
try {
//First stop the current listener
epiccash.LibEpiccash.stopEpicboxListener();
final wallet =
await secureStorageInterface.read(key: '${walletId}_wallet');
// max number of blocks to scan per loop iteration
const scanChunkSize = 10000;
// force firing of scan progress event
await getSyncPercent;
// fetch current chain height and last scanned block (should be the
// restore height if full rescan or a wallet restore)
int chainHeight = await this.chainHeight;
int lastScannedBlock = info.epicData!.lastScannedBlock;
// loop while scanning in chain in chunks (of blocks?)
while (lastScannedBlock < chainHeight) {
Logging.instance.log(
"chainHeight: $chainHeight, lastScannedBlock: $lastScannedBlock",
level: LogLevel.Info,
);
final int nextScannedBlock = await epiccash.LibEpiccash.scanOutputs(
wallet: wallet!,
startHeight: lastScannedBlock,
numberOfBlocks: scanChunkSize,
);
// update local cache
await info.updateExtraEpiccashWalletInfo(
epicData: info.epicData!.copyWith(
lastScannedBlock: nextScannedBlock,
),
isar: mainDB.isar,
);
// force firing of scan progress event
await getSyncPercent;
// update while loop condition variables
chainHeight = await this.chainHeight;
lastScannedBlock = nextScannedBlock;
}
Logging.instance.log(
"_startScans successfully at the tip",
level: LogLevel.Info,
);
//Once scanner completes restart listener
await _listenToEpicbox();
} catch (e, s) {
Logging.instance.log(
"_startScans failed: $e\n$s",
level: LogLevel.Error,
);
rethrow;
}
}
Future<void> _listenToEpicbox() async {
Logging.instance.log("STARTING WALLET LISTENER ....", level: LogLevel.Info);
final wallet = await secureStorageInterface.read(key: '${walletId}_wallet');
final EpicBoxConfigModel epicboxConfig = await getEpicBoxConfig();
epiccash.LibEpiccash.startEpicboxListener(
wallet: wallet!,
epicboxConfig: epicboxConfig.toString(),
);
}
// As opposed to fake config?
Future<String> _getRealConfig() async {
String? config = await secureStorageInterface.read(
key: '${walletId}_config',
);
if (Platform.isIOS) {
final walletDir = await _currentWalletDirPath();
final editConfig = jsonDecode(config as String);
editConfig["wallet_dir"] = walletDir;
config = jsonEncode(editConfig);
}
return config!;
}
// TODO: make more robust estimate of date maybe using https://explorer.epic.tech/api-index
int _calculateRestoreHeightFrom({required DateTime date}) {
final int secondsSinceEpoch = date.millisecondsSinceEpoch ~/ 1000;
const int epicCashFirstBlock = 1565370278;
const double overestimateSecondsPerBlock = 61;
final int chosenSeconds = secondsSinceEpoch - epicCashFirstBlock;
final int approximateHeight = chosenSeconds ~/ overestimateSecondsPerBlock;
int height = approximateHeight;
if (height < 0) {
height = 0;
}
return height;
}
// ============== Overrides ==================================================
@override
int get isarTransactionVersion => 2;
@override
FilterOperation? get changeAddressFilterOperation =>
FilterGroup.and(standardChangeAddressFilters);
@override
FilterOperation? get receivingAddressFilterOperation =>
FilterGroup.and(standardReceivingAddressFilters);
@override
Future<void> checkSaveInitialReceivingAddress() async {
// epiccash seems ok with nothing here?
}
@override
Future<void> init({bool? isRestore}) async {
if (isRestore != true) {
String? encodedWallet =
await secureStorageInterface.read(key: "${walletId}_wallet");
// check if should create a new wallet
if (encodedWallet == null) {
await updateNode();
final mnemonicString = await getMnemonic();
final String password = generatePassword();
final String stringConfig = await _getConfig();
final EpicBoxConfigModel epicboxConfig = await getEpicBoxConfig();
await secureStorageInterface.write(
key: '${walletId}_config', value: stringConfig);
await secureStorageInterface.write(
key: '${walletId}_password', value: password);
await secureStorageInterface.write(
key: '${walletId}_epicboxConfig', value: epicboxConfig.toString());
final String name = walletId;
await epiccash.LibEpiccash.initializeNewWallet(
config: stringConfig,
mnemonic: mnemonicString,
password: password,
name: name,
);
//Open wallet
encodedWallet = await epiccash.LibEpiccash.openWallet(
config: stringConfig,
password: password,
);
await secureStorageInterface.write(
key: '${walletId}_wallet',
value: encodedWallet,
);
//Store Epic box address info
await _generateAndStoreReceivingAddressForIndex(0);
// subtract a couple days to ensure we have a buffer for SWB
final bufferedCreateHeight = _calculateRestoreHeightFrom(
date: DateTime.now().subtract(const Duration(days: 2)));
final epicData = ExtraEpiccashWalletInfo(
receivingIndex: 0,
changeIndex: 0,
slatesToAddresses: {},
slatesToCommits: {},
lastScannedBlock: bufferedCreateHeight,
restoreHeight: bufferedCreateHeight,
creationHeight: bufferedCreateHeight,
);
await info.updateExtraEpiccashWalletInfo(
epicData: epicData,
isar: mainDB.isar,
);
} else {
try {
Logging.instance.log(
"initializeExisting() ${cryptoCurrency.coin.prettyName} wallet",
level: LogLevel.Info);
final config = await _getRealConfig();
final password =
await secureStorageInterface.read(key: '${walletId}_password');
final walletOpen = await epiccash.LibEpiccash.openWallet(
config: config,
password: password!,
);
await secureStorageInterface.write(
key: '${walletId}_wallet', value: walletOpen);
await updateNode();
} catch (e, s) {
// do nothing, still allow user into wallet
Logging.instance.log(
"$runtimeType init() failed: $e\n$s",
level: LogLevel.Error,
);
}
}
}
return await super.init();
}
@override
Future<TxData> confirmSend({required TxData txData}) async {
try {
final wallet =
await secureStorageInterface.read(key: '${walletId}_wallet');
final EpicBoxConfigModel epicboxConfig = await getEpicBoxConfig();
// TODO determine whether it is worth sending change to a change address.
final String receiverAddress = txData.recipients!.first.address;
if (!receiverAddress.startsWith("http://") ||
!receiverAddress.startsWith("https://")) {
final bool isEpicboxConnected = await _testEpicboxServer(
epicboxConfig,
);
if (!isEpicboxConnected) {
throw Exception("Failed to send TX : Unable to reach epicbox server");
}
}
({String commitId, String slateId}) transaction;
if (receiverAddress.startsWith("http://") ||
receiverAddress.startsWith("https://")) {
transaction = await epiccash.LibEpiccash.txHttpSend(
wallet: wallet!,
selectionStrategyIsAll: 0,
minimumConfirmations: cryptoCurrency.minConfirms,
message: txData.noteOnChain!,
amount: txData.recipients!.first.amount.raw.toInt(),
address: txData.recipients!.first.address,
);
} else {
transaction = await epiccash.LibEpiccash.createTransaction(
wallet: wallet!,
amount: txData.recipients!.first.amount.raw.toInt(),
address: txData.recipients!.first.address,
secretKeyIndex: 0,
epicboxConfig: epicboxConfig.toString(),
minimumConfirmations: cryptoCurrency.minConfirms,
note: txData.noteOnChain!,
);
}
final Map<String, String> txAddressInfo = {};
txAddressInfo['from'] = (await getCurrentReceivingAddress())!.value;
txAddressInfo['to'] = txData.recipients!.first.address;
await _putSendToAddresses(transaction, txAddressInfo);
return txData.copyWith(
txid: transaction.slateId,
);
} catch (e, s) {
Logging.instance.log(
"Epic cash confirmSend: $e\n$s",
level: LogLevel.Error,
);
rethrow;
}
}
@override
Future<TxData> prepareSend({required TxData txData}) async {
try {
if (txData.recipients?.length != 1) {
throw Exception("Epic cash prepare send requires a single recipient!");
}
({String address, Amount amount, bool isChange}) recipient =
txData.recipients!.first;
final int realFee = await _nativeFee(recipient.amount.raw.toInt());
final feeAmount = Amount(
rawValue: BigInt.from(realFee),
fractionDigits: cryptoCurrency.fractionDigits,
);
if (feeAmount > info.cachedBalance.spendable) {
throw Exception(
"Epic cash prepare send fee is greater than available balance!");
}
if (info.cachedBalance.spendable == recipient.amount) {
recipient = (
address: recipient.address,
amount: recipient.amount - feeAmount,
isChange: recipient.isChange,
);
}
return txData.copyWith(
recipients: [recipient],
fee: feeAmount,
);
} catch (e, s) {
Logging.instance
.log("Epic cash prepareSend: $e\n$s", level: LogLevel.Error);
rethrow;
}
}
@override
Future<void> recover({required bool isRescan}) async {
try {
await refreshMutex.protect(() async {
if (isRescan) {
// clear blockchain info
await mainDB.deleteWalletBlockchainData(walletId);
await info.updateExtraEpiccashWalletInfo(
epicData: info.epicData!.copyWith(
lastScannedBlock: info.epicData!.restoreHeight,
),
isar: mainDB.isar,
);
unawaited(_startScans());
} else {
await updateNode();
final String password = generatePassword();
final String stringConfig = await _getConfig();
final EpicBoxConfigModel epicboxConfig = await getEpicBoxConfig();
await secureStorageInterface.write(
key: '${walletId}_config',
value: stringConfig,
);
await secureStorageInterface.write(
key: '${walletId}_password',
value: password,
);
await secureStorageInterface.write(
key: '${walletId}_epicboxConfig',
value: epicboxConfig.toString(),
);
await epiccash.LibEpiccash.recoverWallet(
config: stringConfig,
password: password,
mnemonic: await getMnemonic(),
name: info.walletId,
);
final epicData = ExtraEpiccashWalletInfo(
receivingIndex: 0,
changeIndex: 0,
slatesToAddresses: {},
slatesToCommits: {},
lastScannedBlock: info.restoreHeight,
restoreHeight: info.restoreHeight,
creationHeight: info.epicData?.creationHeight ?? info.restoreHeight,
);
await info.updateExtraEpiccashWalletInfo(
epicData: epicData,
isar: mainDB.isar,
);
//Open Wallet
final walletOpen = await epiccash.LibEpiccash.openWallet(
config: stringConfig,
password: password,
);
await secureStorageInterface.write(
key: '${walletId}_wallet',
value: walletOpen,
);
await _generateAndStoreReceivingAddressForIndex(
epicData.receivingIndex);
}
});
unawaited(refresh());
} catch (e, s) {
Logging.instance.log(
"Exception rethrown from electrumx_mixin recover(): $e\n$s",
level: LogLevel.Info);
rethrow;
}
}
@override
Future<void> refresh() async {
// Awaiting this lock could be dangerous.
// Since refresh is periodic (generally)
if (refreshMutex.isLocked) {
return;
}
try {
// this acquire should be almost instant due to above check.
// Slight possibility of race but should be irrelevant
await refreshMutex.acquire();
GlobalEventBus.instance.fire(
WalletSyncStatusChangedEvent(
WalletSyncStatus.syncing,
walletId,
cryptoCurrency.coin,
),
);
// if (info.epicData?.creationHeight == null) {
// await info.updateExtraEpiccashWalletInfo(epicData: inf, isar: isar)
// await epicUpdateCreationHeight(await chainHeight);
// }
// this will always be zero????
final int curAdd = await _getCurrentIndex();
await _generateAndStoreReceivingAddressForIndex(curAdd);
await _startScans();
unawaited(_startSync());
GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.0, walletId));
await updateChainHeight();
GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.1, walletId));
// if (this is MultiAddressInterface) {
// await (this as MultiAddressInterface)
// .checkReceivingAddressForTransactions();
// }
GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.2, walletId));
// // TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided.
// if (this is MultiAddressInterface) {
// await (this as MultiAddressInterface)
// .checkChangeAddressForTransactions();
// }
GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.3, walletId));
GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.50, walletId));
final fetchFuture = updateTransactions();
// if (currentHeight != storedHeight) {
GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.60, walletId));
GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.70, walletId));
await fetchFuture;
GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.80, walletId));
// await getAllTxsToWatch();
GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.90, walletId));
await updateBalance();
GlobalEventBus.instance.fire(RefreshPercentChangedEvent(1.0, walletId));
GlobalEventBus.instance.fire(
WalletSyncStatusChangedEvent(
WalletSyncStatus.synced,
walletId,
cryptoCurrency.coin,
),
);
if (shouldAutoSync) {
timer ??= Timer.periodic(const Duration(seconds: 150), (timer) async {
// chain height check currently broken
// if ((await chainHeight) != (await storedChainHeight)) {
// TODO: [prio=med] some kind of quick check if wallet needs to refresh to replace the old refreshIfThereIsNewData call
// if (await refreshIfThereIsNewData()) {
unawaited(refresh());
// }
// }
});
}
} catch (error, strace) {
GlobalEventBus.instance.fire(
NodeConnectionStatusChangedEvent(
NodeConnectionStatus.disconnected,
walletId,
cryptoCurrency.coin,
),
);
GlobalEventBus.instance.fire(
WalletSyncStatusChangedEvent(
WalletSyncStatus.unableToSync,
walletId,
cryptoCurrency.coin,
),
);
Logging.instance.log(
"Caught exception in refreshWalletData(): $error\n$strace",
level: LogLevel.Error,
);
} finally {
refreshMutex.release();
}
}
@override
Future<void> updateBalance() async {
try {
final balances = await _allWalletBalances();
final balance = Balance(
total: Amount.fromDecimal(
Decimal.parse(balances.total.toString()) +
Decimal.parse(balances.awaitingFinalization.toString()),
fractionDigits: cryptoCurrency.fractionDigits,
),
spendable: Amount.fromDecimal(
Decimal.parse(balances.spendable.toString()),
fractionDigits: cryptoCurrency.fractionDigits,
),
blockedTotal: Amount.zeroWith(
fractionDigits: cryptoCurrency.fractionDigits,
),
pendingSpendable: Amount.fromDecimal(
Decimal.parse(balances.pending.toString()),
fractionDigits: cryptoCurrency.fractionDigits,
),
);
await info.updateBalance(
newBalance: balance,
isar: mainDB.isar,
);
} catch (e, s) {
Logging.instance.log(
"Epic cash wallet failed to update balance: $e\n$s",
level: LogLevel.Warning,
);
}
}
@override
Future<void> updateTransactions() async {
try {
final wallet =
await secureStorageInterface.read(key: '${walletId}_wallet');
const refreshFromNode = 1;
final myAddresses = await mainDB
.getAddresses(walletId)
.filter()
.typeEqualTo(AddressType.mimbleWimble)
.and()
.subTypeEqualTo(AddressSubType.receiving)
.and()
.valueIsNotEmpty()
.valueProperty()
.findAll();
final myAddressesSet = myAddresses.toSet();
final transactions = await epiccash.LibEpiccash.getTransactions(
wallet: wallet!,
refreshFromNode: refreshFromNode,
);
final List<TransactionV2> txns = [];
final slatesToCommits = info.epicData?.slatesToCommits ?? {};
for (final tx in transactions) {
Logging.instance.log("tx: $tx", level: LogLevel.Info);
final isIncoming =
tx.txType == epic_models.TransactionType.TxReceived ||
tx.txType == epic_models.TransactionType.TxReceivedCancelled;
final slateId = tx.txSlateId;
final commitId = slatesToCommits[slateId]?['commitId'] as String?;
final numberOfMessages = tx.messages?.messages.length;
final onChainNote = tx.messages?.messages[0].message;
final addressFrom = slatesToCommits[slateId]?["from"] as String?;
final addressTo = slatesToCommits[slateId]?["to"] as String?;
final credit = int.parse(tx.amountCredited);
final debit = int.parse(tx.amountDebited);
final fee = int.tryParse(tx.fee ?? "0") ?? 0;
// hack epic tx data into inputs and outputs
final List<OutputV2> outputs = [];
final List<InputV2> inputs = [];
final addressFromIsMine = myAddressesSet.contains(addressFrom);
final addressToIsMine = myAddressesSet.contains(addressTo);
OutputV2 output = OutputV2.isarCantDoRequiredInDefaultConstructor(
scriptPubKeyHex: "00",
valueStringSats: credit.toString(),
addresses: [
if (addressFrom != null) addressFrom,
],
walletOwns: true,
);
final InputV2 input = InputV2.isarCantDoRequiredInDefaultConstructor(
scriptSigHex: null,
scriptSigAsm: null,
sequence: null,
outpoint: null,
addresses: [if (addressTo != null) addressTo],
valueStringSats: debit.toString(),
witness: null,
innerRedeemScriptAsm: null,
coinbase: null,
walletOwns: true,
);
final TransactionType txType;
if (isIncoming) {
if (addressToIsMine && addressFromIsMine) {
txType = TransactionType.sentToSelf;
} else {
txType = TransactionType.incoming;
}
output = output.copyWith(
addresses: [
myAddressesSet
.first, // Must be changed if we ever do more than a single wallet address!!!
],
walletOwns: true,
);
} else {
txType = TransactionType.outgoing;
}
outputs.add(output);
inputs.add(input);
final otherData = {
"isEpiccashTransaction": true,
"numberOfMessages": numberOfMessages,
"slateId": slateId,
"onChainNote": onChainNote,
"isCancelled":
tx.txType == epic_models.TransactionType.TxSentCancelled ||
tx.txType == epic_models.TransactionType.TxReceivedCancelled,
"overrideFee": Amount(
rawValue: BigInt.from(fee),
fractionDigits: cryptoCurrency.fractionDigits,
).toJsonString(),
};
final txn = TransactionV2(
walletId: walletId,
blockHash: null,
hash: commitId ?? tx.id.toString(),
txid: commitId ?? tx.id.toString(),
timestamp:
DateTime.parse(tx.creationTs).millisecondsSinceEpoch ~/ 1000,
height: tx.confirmed ? tx.kernelLookupMinHeight ?? 1 : null,
inputs: List.unmodifiable(inputs),
outputs: List.unmodifiable(outputs),
version: 0,
type: txType,
subType: TransactionSubType.none,
otherData: jsonEncode(otherData),
);
txns.add(txn);
}
await mainDB.isar.writeTxn(() async {
await mainDB.isar.transactionV2s
.where()
.walletIdEqualTo(walletId)
.deleteAll();
await mainDB.isar.transactionV2s.putAll(txns);
});
} catch (e, s) {
Logging.instance.log(
"${cryptoCurrency.runtimeType} ${cryptoCurrency.network} net wallet"
" \"${info.name}\"_${info.walletId} updateTransactions() failed: $e\n$s",
level: LogLevel.Warning,
);
}
}
@override
Future<bool> updateUTXOs() async {
// not used for epiccash
return false;
}
@override
Future<void> updateNode() async {
_epicNode = getCurrentNode();
// TODO: [prio=low] move this out of secure storage if secure storage not needed
final String stringConfig = await _getConfig();
await secureStorageInterface.write(
key: '${walletId}_config',
value: stringConfig,
);
// unawaited(refresh());
}
@override
Future<bool> pingCheck() async {
try {
final node = nodeService.getPrimaryNodeFor(coin: cryptoCurrency.coin);
// force unwrap optional as we want connection test to fail if wallet
// wasn't initialized or epicbox node was set to null
return await testEpicNodeConnection(
NodeFormData()
..host = node!.host
..useSSL = node.useSSL
..port = node.port,
) !=
null;
} catch (e, s) {
Logging.instance.log("$e\n$s", level: LogLevel.Info);
return false;
}
}
@override
Future<void> updateChainHeight() async {
final config = await _getRealConfig();
final latestHeight =
await epiccash.LibEpiccash.getChainHeight(config: config);
await info.updateCachedChainHeight(
newHeight: latestHeight,
isar: mainDB.isar,
);
}
@override
Future<Amount> estimateFeeFor(Amount amount, int feeRate) async {
// setting ifErrorEstimateFee doesn't do anything as its not used in the nativeFee function?????
final int currentFee = await _nativeFee(
amount.raw.toInt(),
ifErrorEstimateFee: true,
);
return Amount(
rawValue: BigInt.from(currentFee),
fractionDigits: cryptoCurrency.fractionDigits,
);
}
@override
Future<FeeObject> get fees async {
// this wasn't done before the refactor either so...
// TODO: implement _getFees
return FeeObject(
numberOfBlocksFast: 10,
numberOfBlocksAverage: 10,
numberOfBlocksSlow: 10,
fast: 1,
medium: 1,
slow: 1);
}
@override
Future<TxData> updateSentCachedTxData({required TxData txData}) async {
// TODO: [prio=low] Was not used before refactor so maybe not required(?)
return txData;
}
@override
Future<void> exit() async {
timer?.cancel();
timer = null;
await super.exit();
Logging.instance.log("EpicCash_wallet exit finished", level: LogLevel.Info);
}
}
Future<String> deleteEpicWallet({
required String walletId,
required SecureStorageInterface secureStore,
}) async {
final wallet = await secureStore.read(key: '${walletId}_wallet');
String? config = await secureStore.read(key: '${walletId}_config');
if (Platform.isIOS) {
final Directory appDir = await StackFileSystem.applicationRootDirectory();
final path = "${appDir.path}/epiccash";
final String name = walletId.trim();
final walletDir = '$path/$name';
final editConfig = jsonDecode(config as String);
editConfig["wallet_dir"] = walletDir;
config = jsonEncode(editConfig);
}
if (wallet == null) {
return "Tried to delete non existent epic wallet file with walletId=$walletId";
} else {
try {
return epiccash.LibEpiccash.deleteWallet(
wallet: wallet,
config: config!,
);
} catch (e, s) {
Logging.instance.log("$e\n$s", level: LogLevel.Error);
return "deleteEpicWallet($walletId) failed...";
}
}
}