476 lines
13 KiB
Dart
476 lines
13 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/scheduler.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:logger/logger.dart';
|
|
import 'package:path/path.dart' as p;
|
|
|
|
import '../data/wallet_repository.dart';
|
|
import '../domain/models.dart';
|
|
import '../domain/send.dart';
|
|
import '../domain/staking.dart';
|
|
import '../domain/transactions.dart';
|
|
import '../services/wallet_cache_recovery.dart';
|
|
|
|
class WalletState {
|
|
const WalletState({
|
|
required this.walletInfo,
|
|
required this.balanceAtomic,
|
|
required this.unlockedAtomic,
|
|
required this.transactions,
|
|
required this.subaddresses,
|
|
required this.syncStatus,
|
|
required this.isLoading,
|
|
required this.isSyncing,
|
|
required this.error,
|
|
});
|
|
|
|
final WalletInfo? walletInfo;
|
|
final int balanceAtomic;
|
|
final int unlockedAtomic;
|
|
final List<WalletTransaction> transactions;
|
|
final List<SubaddressInfo> subaddresses;
|
|
final SyncStatus syncStatus;
|
|
final bool isLoading;
|
|
final bool isSyncing;
|
|
final String? error;
|
|
|
|
WalletState copyWith({
|
|
WalletInfo? walletInfo,
|
|
int? balanceAtomic,
|
|
int? unlockedAtomic,
|
|
List<WalletTransaction>? transactions,
|
|
List<SubaddressInfo>? subaddresses,
|
|
SyncStatus? syncStatus,
|
|
bool? isLoading,
|
|
bool? isSyncing,
|
|
String? error,
|
|
}) {
|
|
return WalletState(
|
|
walletInfo: walletInfo ?? this.walletInfo,
|
|
balanceAtomic: balanceAtomic ?? this.balanceAtomic,
|
|
unlockedAtomic: unlockedAtomic ?? this.unlockedAtomic,
|
|
transactions: transactions ?? this.transactions,
|
|
subaddresses: subaddresses ?? this.subaddresses,
|
|
syncStatus: syncStatus ?? this.syncStatus,
|
|
isLoading: isLoading ?? this.isLoading,
|
|
isSyncing: isSyncing ?? this.isSyncing,
|
|
error: error,
|
|
);
|
|
}
|
|
|
|
factory WalletState.initial() {
|
|
return WalletState(
|
|
walletInfo: null,
|
|
balanceAtomic: 0,
|
|
unlockedAtomic: 0,
|
|
transactions: const [],
|
|
subaddresses: const [],
|
|
syncStatus: SyncStatus.initial(),
|
|
isLoading: false,
|
|
isSyncing: false,
|
|
error: null,
|
|
);
|
|
}
|
|
}
|
|
|
|
class WalletController extends StateNotifier<WalletState> {
|
|
WalletController({
|
|
required WalletRepository repository,
|
|
required Logger logger,
|
|
required AppConfig Function() readConfig,
|
|
}) : _repository = repository,
|
|
_logger = logger,
|
|
_readConfig = readConfig,
|
|
_cacheRecovery = WalletCacheRecovery(logger: logger),
|
|
super(WalletState.initial());
|
|
|
|
final WalletRepository _repository;
|
|
final Logger _logger;
|
|
final AppConfig Function() _readConfig;
|
|
final WalletCacheRecovery _cacheRecovery;
|
|
|
|
Future<bool> tryAutoOpen(AppConfig config) async {
|
|
final wallet = config.lastWallet;
|
|
if (wallet == null) {
|
|
return false;
|
|
}
|
|
final resolvedPath = _resolveOpenPath(wallet.path);
|
|
if (_isDirectoryPath(resolvedPath)) {
|
|
state = state.copyWith(error: 'Wallet file not found in ${wallet.path}');
|
|
return false;
|
|
}
|
|
if (!File(resolvedPath).existsSync() &&
|
|
!File('$resolvedPath.keys').existsSync()) {
|
|
state = state.copyWith(error: 'Wallet not found at ${wallet.path}');
|
|
return false;
|
|
}
|
|
if (!await verifyWalletPassword(path: resolvedPath, password: '')) {
|
|
return true;
|
|
}
|
|
await _cacheRecovery.recoverIfNeeded(resolvedPath);
|
|
await openWallet(path: resolvedPath, password: '');
|
|
return false;
|
|
}
|
|
|
|
Future<void> createWallet({
|
|
required String name,
|
|
required String path,
|
|
required String password,
|
|
}) async {
|
|
await _runWithLoading(() async {
|
|
await Directory(path).create(recursive: true);
|
|
final resolvedPath = _resolveWalletFilePath(path, name: name);
|
|
await _cacheRecovery.markOpenAttempt(resolvedPath);
|
|
await _repository.createWallet(
|
|
name: name, path: path, password: password);
|
|
try {
|
|
await _postOpen(name: name, path: resolvedPath);
|
|
} finally {
|
|
await _cacheRecovery.clearOpenAttempt();
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<String> prepareWalletSeedDraft({
|
|
required String name,
|
|
required String password,
|
|
}) async {
|
|
return _runWithLoading(() async {
|
|
final tempDir = await Directory.systemTemp.createTemp('peyawallet-draft-');
|
|
try {
|
|
await _repository.createWallet(
|
|
name: name,
|
|
path: tempDir.path,
|
|
password: password,
|
|
);
|
|
final seed = await _repository.getSeed();
|
|
await _repository.closeWallet();
|
|
return seed;
|
|
} finally {
|
|
try {
|
|
await _repository.closeWallet();
|
|
} catch (_) {}
|
|
if (tempDir.existsSync()) {
|
|
await tempDir.delete(recursive: true);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> restoreWalletFromSeed({
|
|
required String name,
|
|
required String seed,
|
|
required String path,
|
|
required String password,
|
|
}) async {
|
|
await _runWithLoading(() async {
|
|
await Directory(path).create(recursive: true);
|
|
final resolvedPath = _resolveWalletFilePath(path, name: name);
|
|
await _cacheRecovery.markOpenAttempt(resolvedPath);
|
|
await _repository.restoreWalletFromSeed(
|
|
name: name,
|
|
seed: seed,
|
|
path: path,
|
|
password: password,
|
|
);
|
|
try {
|
|
await _postOpen(name: name, path: resolvedPath);
|
|
} finally {
|
|
await _cacheRecovery.clearOpenAttempt();
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> openWallet({
|
|
required String path,
|
|
required String password,
|
|
}) async {
|
|
await _runWithLoading(() async {
|
|
final resolvedPath = _resolveOpenPath(path);
|
|
await _cacheRecovery.recoverIfNeeded(resolvedPath);
|
|
await _cacheRecovery.markOpenAttempt(resolvedPath);
|
|
try {
|
|
await _repository.openWallet(path: resolvedPath, password: password);
|
|
await _postOpen(name: p.basename(resolvedPath), path: resolvedPath);
|
|
} finally {
|
|
await _cacheRecovery.clearOpenAttempt();
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<bool> verifyWalletPassword({
|
|
required String path,
|
|
required String password,
|
|
}) async {
|
|
final resolvedPath = _resolveOpenPath(path);
|
|
return _repository.verifyWalletPassword(
|
|
path: resolvedPath,
|
|
password: password,
|
|
);
|
|
}
|
|
|
|
Future<bool> walletRequiresPassword(String path) async {
|
|
return !await verifyWalletPassword(path: path, password: '');
|
|
}
|
|
|
|
Future<void> connectNode(NodeConfig nodeConfig) async {
|
|
await _runWithLoading(() async {
|
|
await _repository.connectNode(nodeConfig);
|
|
});
|
|
}
|
|
|
|
Future<void> switchWallet() async {
|
|
await _repository.closeWallet();
|
|
await _cacheRecovery.clearOpenAttempt();
|
|
state = WalletState.initial();
|
|
}
|
|
|
|
Future<String> getSeed() async {
|
|
if (state.walletInfo == null) {
|
|
throw StateError('No wallet loaded');
|
|
}
|
|
return _repository.getSeed();
|
|
}
|
|
|
|
Future<String> createSubaddress({String? label}) async {
|
|
if (state.walletInfo == null) {
|
|
throw StateError('No wallet loaded');
|
|
}
|
|
return _runWithLoading(() => _repository.createSubaddress(label: label));
|
|
}
|
|
|
|
Future<void> setSubaddressLabel({
|
|
required int accountIndex,
|
|
required int addressIndex,
|
|
required String label,
|
|
}) async {
|
|
if (state.walletInfo == null) {
|
|
throw StateError('No wallet loaded');
|
|
}
|
|
await _runWithLoading(() {
|
|
return _repository.setSubaddressLabel(
|
|
accountIndex: accountIndex,
|
|
addressIndex: addressIndex,
|
|
label: label,
|
|
);
|
|
});
|
|
await refreshSubaddresses();
|
|
}
|
|
|
|
Future<void> refreshSubaddresses() async {
|
|
if (state.walletInfo == null) {
|
|
return;
|
|
}
|
|
final subaddresses = await _repository.getSubaddresses();
|
|
state = state.copyWith(subaddresses: subaddresses);
|
|
}
|
|
|
|
Future<SendPreview> prepareSend(SendRequest request) async {
|
|
if (state.walletInfo == null) {
|
|
throw StateError('No wallet loaded');
|
|
}
|
|
return _runWithLoading(() => _repository.prepareSend(request));
|
|
}
|
|
|
|
Future<SendResult> commitPreparedSend() async {
|
|
if (state.walletInfo == null) {
|
|
throw StateError('No wallet loaded');
|
|
}
|
|
return _runWithLoading(() async {
|
|
final result = await _repository.commitPreparedSend();
|
|
await _refreshSnapshot();
|
|
return result;
|
|
});
|
|
}
|
|
|
|
Future<void> discardPreparedSend() async {
|
|
await _repository.discardPreparedSend();
|
|
}
|
|
|
|
Future<StakePreview> prepareStake({
|
|
required int amountAtomic,
|
|
int accountIndex = 0,
|
|
}) async {
|
|
if (state.walletInfo == null) {
|
|
throw StateError('No wallet loaded');
|
|
}
|
|
return _runWithLoading(() {
|
|
return _repository.prepareStake(
|
|
amountAtomic: amountAtomic,
|
|
accountIndex: accountIndex,
|
|
);
|
|
});
|
|
}
|
|
|
|
Future<Map<String, StakeYieldInfo>> getActiveStakeYields({
|
|
required List<WalletTransaction> activeStakes,
|
|
required int currentHeight,
|
|
}) async {
|
|
if (state.walletInfo == null || activeStakes.isEmpty) {
|
|
return const {};
|
|
}
|
|
return _repository.getActiveStakeYields(
|
|
activeStakes: activeStakes,
|
|
currentHeight: currentHeight,
|
|
);
|
|
}
|
|
|
|
Future<StakeResult> commitPreparedStake() async {
|
|
if (state.walletInfo == null) {
|
|
throw StateError('No wallet loaded');
|
|
}
|
|
return _runWithLoading(() async {
|
|
final result = await _repository.commitPreparedStake();
|
|
await _refreshSnapshot();
|
|
return result;
|
|
});
|
|
}
|
|
|
|
Future<void> discardPreparedStake() async {
|
|
await _repository.discardPreparedStake();
|
|
}
|
|
|
|
Future<void> syncNow() async {
|
|
if (state.isSyncing || state.walletInfo == null) {
|
|
return;
|
|
}
|
|
state = state.copyWith(isSyncing: true);
|
|
try {
|
|
await _ensureConnected();
|
|
await _refreshSnapshot();
|
|
} catch (error, stack) {
|
|
_logger.e('Sync failed', error: error, stackTrace: stack);
|
|
state = state.copyWith(error: error.toString());
|
|
} finally {
|
|
state = state.copyWith(isSyncing: false);
|
|
}
|
|
}
|
|
|
|
Future<void> rescanBlockchain({int? fromHeight}) async {
|
|
if (state.walletInfo == null) {
|
|
return;
|
|
}
|
|
state = state.copyWith(isSyncing: true, error: null);
|
|
try {
|
|
await _ensureConnected();
|
|
await _repository.rescanBlockchain(fromHeight: fromHeight);
|
|
await _refreshSnapshot();
|
|
} catch (error, stack) {
|
|
_logger.e('Rescan failed', error: error, stackTrace: stack);
|
|
state = state.copyWith(error: error.toString());
|
|
} finally {
|
|
state = state.copyWith(isSyncing: false);
|
|
}
|
|
}
|
|
|
|
Future<void> _ensureConnected() async {
|
|
if (await _repository.isConnected()) {
|
|
return;
|
|
}
|
|
final config = _readConfig();
|
|
await _repository.connectNode(config.nodeConfig);
|
|
if (!await _repository.isConnected()) {
|
|
throw StateError('Failed to connect to node');
|
|
}
|
|
}
|
|
|
|
Future<void> _postOpen({required String name, required String path}) async {
|
|
final address = await _repository.getAddress();
|
|
final subaddresses = await _repository.getSubaddresses();
|
|
state = state.copyWith(
|
|
walletInfo: WalletInfo(name: name, path: path, address: address),
|
|
balanceAtomic: 0,
|
|
syncStatus: SyncStatus.initial(),
|
|
transactions: const [],
|
|
subaddresses: subaddresses,
|
|
error: null,
|
|
);
|
|
}
|
|
|
|
Future<WalletSnapshot> _refreshSnapshot() async {
|
|
final snapshot = await _repository.refresh();
|
|
final transactions = await _repository.getTransactions();
|
|
final syncStatus = SyncStatus(
|
|
synced: snapshot.synced,
|
|
progress: snapshot.progress,
|
|
nodeHeight: snapshot.nodeHeight,
|
|
walletHeight: snapshot.walletHeight,
|
|
lastSync: DateTime.now(),
|
|
);
|
|
final updatedWalletInfo =
|
|
state.walletInfo?.copyWith(address: snapshot.address);
|
|
state = state.copyWith(
|
|
walletInfo: updatedWalletInfo ?? state.walletInfo,
|
|
balanceAtomic: snapshot.balanceAtomic,
|
|
unlockedAtomic: snapshot.unlockedAtomic,
|
|
transactions: transactions,
|
|
subaddresses: snapshot.subaddresses.isEmpty
|
|
? state.subaddresses
|
|
: snapshot.subaddresses,
|
|
syncStatus: syncStatus,
|
|
error: null,
|
|
);
|
|
return snapshot;
|
|
}
|
|
|
|
Future<T> _runWithLoading<T>(Future<T> Function() action) async {
|
|
state = state.copyWith(isLoading: true, error: null);
|
|
try {
|
|
await SchedulerBinding.instance.endOfFrame;
|
|
return await action();
|
|
} catch (error, stack) {
|
|
_logger.e('Wallet action failed', error: error, stackTrace: stack);
|
|
state = state.copyWith(error: error.toString());
|
|
rethrow;
|
|
} finally {
|
|
state = state.copyWith(isLoading: false);
|
|
}
|
|
}
|
|
|
|
String _resolveWalletFilePath(String basePath, {required String name}) {
|
|
if (basePath.isEmpty) {
|
|
return basePath;
|
|
}
|
|
if (_isDirectoryPath(basePath)) {
|
|
return p.join(basePath, name);
|
|
}
|
|
return basePath;
|
|
}
|
|
|
|
String _resolveOpenPath(String basePath) {
|
|
if (!_isDirectoryPath(basePath)) {
|
|
return basePath;
|
|
}
|
|
final dirName = p.basename(basePath);
|
|
final candidate = p.join(basePath, dirName);
|
|
if (File(candidate).existsSync() || File('$candidate.keys').existsSync()) {
|
|
return candidate;
|
|
}
|
|
final dir = Directory(basePath);
|
|
if (!dir.existsSync()) {
|
|
return basePath;
|
|
}
|
|
final entries = dir
|
|
.listSync()
|
|
.whereType<File>()
|
|
.map((entry) => entry.path)
|
|
.where((entryPath) => !entryPath.endsWith('.keys'))
|
|
.toList()
|
|
..sort();
|
|
for (final entryPath in entries) {
|
|
if (File('$entryPath.keys').existsSync()) {
|
|
return entryPath;
|
|
}
|
|
}
|
|
return basePath;
|
|
}
|
|
|
|
bool _isDirectoryPath(String path) {
|
|
if (path.endsWith(p.separator)) {
|
|
return true;
|
|
}
|
|
return Directory(path).existsSync();
|
|
}
|
|
}
|