1595 lines
48 KiB
Dart
1595 lines
48 KiB
Dart
import 'dart:ffi';
|
|
import 'dart:io';
|
|
import 'dart:isolate';
|
|
|
|
import 'package:logger/logger.dart';
|
|
import 'package:monero/peya.dart' as peya;
|
|
import 'package:ffi/ffi.dart';
|
|
import 'package:path/path.dart' as p;
|
|
|
|
import '../domain/models.dart';
|
|
import '../domain/send.dart';
|
|
import '../domain/staking.dart';
|
|
import '../domain/transactions.dart';
|
|
import 'monero_library_loader.dart';
|
|
import 'wallet_backend.dart';
|
|
|
|
typedef _WalletSetStoreTxInfoNative = Void Function(Pointer<Void>, Uint8);
|
|
typedef _WalletSetStoreTxInfoDart = void Function(Pointer<Void>, int);
|
|
typedef _WalletGetStoreTxInfoNative = Uint8 Function(Pointer<Void>);
|
|
typedef _WalletGetStoreTxInfoDart = int Function(Pointer<Void>);
|
|
typedef _WalletGetYieldInfoRawNative = Pointer<Void> Function(Pointer<Void>);
|
|
typedef _WalletGetYieldInfoRawDart = Pointer<Void> Function(Pointer<Void>);
|
|
typedef _YieldInfoRawCountNative = Int32 Function(Pointer<Void>);
|
|
typedef _YieldInfoRawCountDart = int Function(Pointer<Void>);
|
|
typedef _YieldInfoRawUint64FieldNative = Uint64 Function(Pointer<Void>, Int32);
|
|
typedef _YieldInfoRawUint64FieldDart = int Function(Pointer<Void>, int);
|
|
typedef _YieldInfoRawFreeNative = Void Function(Pointer<Void>);
|
|
typedef _YieldInfoRawFreeDart = void Function(Pointer<Void>);
|
|
|
|
class _SubaddressBalance {
|
|
_SubaddressBalance()
|
|
: total = 0,
|
|
unlocked = 0;
|
|
|
|
int total;
|
|
int unlocked;
|
|
}
|
|
|
|
class MoneroCBackend implements WalletBackend {
|
|
MoneroCBackend(this._logger) {
|
|
final libPath = MoneroLibraryLoader(logger: _logger).locate();
|
|
peya.libPath = libPath;
|
|
_manager = peya.WalletManagerFactory_getWalletManager();
|
|
if (_manager == nullptr) {
|
|
throw StateError('Failed to initialize Peya WalletManager');
|
|
}
|
|
}
|
|
|
|
final Logger _logger;
|
|
peya.WalletManager? _manager;
|
|
peya.wallet? _wallet;
|
|
peya.PendingTransaction? _pendingTransaction;
|
|
DynamicLibrary? _storeTxInfoLib;
|
|
String? _walletPath;
|
|
bool _rescanInProgress = false;
|
|
bool _walletInitialized = false;
|
|
DateTime? _lastStoreAt;
|
|
String? _lastHistoryFingerprint;
|
|
_WalletSetStoreTxInfoDart? _setStoreTxInfo;
|
|
_WalletGetStoreTxInfoDart? _getStoreTxInfo;
|
|
DynamicLibrary? _stakeLib;
|
|
|
|
static const int _defaultMixinCount = 0;
|
|
static const int _defaultPriority = 0;
|
|
static const int _maxDestinations = 16;
|
|
static const Duration _minStoreInterval = Duration(seconds: 10);
|
|
|
|
@override
|
|
int get maxDestinations => _maxDestinations;
|
|
|
|
@override
|
|
Future<void> createWallet({
|
|
required String name,
|
|
required String path,
|
|
required String password,
|
|
}) async {
|
|
_ensureManager();
|
|
final resolvedPath = _resolveWalletFilePath(path, name: name);
|
|
await _runWalletInitInBackground(
|
|
_WalletInitRequest.create(
|
|
libPath: peya.libPath,
|
|
path: resolvedPath,
|
|
password: password,
|
|
),
|
|
);
|
|
await openWallet(path: resolvedPath, password: password);
|
|
}
|
|
|
|
@override
|
|
Future<void> restoreWalletFromSeed({
|
|
required String name,
|
|
required String seed,
|
|
required String path,
|
|
required String password,
|
|
}) async {
|
|
_ensureManager();
|
|
final resolvedPath = _resolveWalletFilePath(path, name: name);
|
|
await _runWalletInitInBackground(
|
|
_WalletInitRequest.restore(
|
|
libPath: peya.libPath,
|
|
path: resolvedPath,
|
|
password: password,
|
|
seed: seed,
|
|
),
|
|
);
|
|
await openWallet(path: resolvedPath, password: password);
|
|
}
|
|
|
|
@override
|
|
Future<void> openWallet(
|
|
{required String path, required String password}) async {
|
|
_ensureManager();
|
|
final resolvedPath = _resolveOpenPath(path);
|
|
if (_isDirectoryPath(resolvedPath)) {
|
|
throw StateError('Wallet file not found in directory: $resolvedPath');
|
|
}
|
|
_wallet = peya.WalletManager_openWallet(
|
|
_manager!,
|
|
path: resolvedPath,
|
|
password: password,
|
|
networkType: 0,
|
|
);
|
|
_walletPath = resolvedPath;
|
|
_walletInitialized = false;
|
|
_ensureWalletCreated();
|
|
}
|
|
|
|
@override
|
|
Future<bool> verifyWalletPassword({
|
|
required String path,
|
|
required String password,
|
|
}) async {
|
|
_ensureManager();
|
|
final resolvedPath = _resolveOpenPath(path);
|
|
final keysFileName = _walletKeysPath(resolvedPath);
|
|
if (!File(keysFileName).existsSync()) {
|
|
throw StateError('Wallet keys file not found at $keysFileName');
|
|
}
|
|
return peya.WalletManager_verifyWalletPassword(
|
|
_manager!,
|
|
keysFileName: keysFileName,
|
|
password: password,
|
|
noSpendKey: false,
|
|
kdfRounds: 1,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<void> closeWallet() async {
|
|
if (_manager == null || _wallet == null) {
|
|
return;
|
|
}
|
|
_storeWallet(reason: 'close', force: true);
|
|
_pendingTransaction = null;
|
|
peya.WalletManager_closeWallet(_manager!, _wallet!, true);
|
|
_wallet = null;
|
|
_walletPath = null;
|
|
_walletInitialized = false;
|
|
}
|
|
|
|
@override
|
|
Future<String> getAddress() async {
|
|
_ensureWallet();
|
|
return peya.Wallet_address(_wallet!, accountIndex: 0, addressIndex: 0);
|
|
}
|
|
|
|
@override
|
|
Future<String> createSubaddress({String? label}) async {
|
|
_ensureWallet();
|
|
const accountIndex = 0;
|
|
final subaddress = peya.Wallet_subaddress(_wallet!);
|
|
final currentCount =
|
|
peya.Wallet_numSubaddresses(_wallet!, accountIndex: accountIndex);
|
|
final subaddressLabel = label ?? '';
|
|
peya.Subaddress_addRow(subaddress,
|
|
accountIndex: accountIndex, label: subaddressLabel);
|
|
peya.Subaddress_refresh(
|
|
subaddress,
|
|
accountIndex: accountIndex,
|
|
label: subaddressLabel,
|
|
);
|
|
final newCount =
|
|
peya.Wallet_numSubaddresses(_wallet!, accountIndex: accountIndex);
|
|
final newIndex = newCount > currentCount ? newCount - 1 : currentCount;
|
|
final address = peya.Wallet_address(_wallet!,
|
|
accountIndex: accountIndex, addressIndex: newIndex);
|
|
if (address.isEmpty) {
|
|
final error = peya.Wallet_errorString(_wallet!);
|
|
throw StateError(error.isEmpty ? 'Failed to create subaddress' : error);
|
|
}
|
|
_storeWallet(reason: 'subaddress');
|
|
return address;
|
|
}
|
|
|
|
@override
|
|
Future<List<SubaddressInfo>> getSubaddresses() async {
|
|
_ensureWallet();
|
|
const accountIndex = 0;
|
|
final subaddress = peya.Wallet_subaddress(_wallet!);
|
|
peya.Subaddress_refresh(
|
|
subaddress,
|
|
accountIndex: accountIndex,
|
|
label: '',
|
|
);
|
|
final count = peya.Subaddress_getAll_size(subaddress);
|
|
final balances = _loadSubaddressBalances(accountIndex);
|
|
final results = <SubaddressInfo>[];
|
|
for (var i = 0; i < count; i++) {
|
|
final row = peya.Subaddress_getAll_byIndex(subaddress, index: i);
|
|
if (row == nullptr) {
|
|
continue;
|
|
}
|
|
final index = peya.SubaddressRow_getRowId(row);
|
|
final address = peya.SubaddressRow_getAddress(row);
|
|
final label = peya.SubaddressRow_getLabel(row);
|
|
final balance = balances[index];
|
|
results.add(
|
|
SubaddressInfo(
|
|
index: index,
|
|
address: address,
|
|
label: label,
|
|
balanceAtomic: balance?.total ?? 0,
|
|
unlockedAtomic: balance?.unlocked ?? 0,
|
|
),
|
|
);
|
|
}
|
|
results.sort((a, b) => a.index.compareTo(b.index));
|
|
return results;
|
|
}
|
|
|
|
@override
|
|
Future<void> setSubaddressLabel({
|
|
required int accountIndex,
|
|
required int addressIndex,
|
|
required String label,
|
|
}) async {
|
|
_ensureWallet();
|
|
final subaddress = peya.Wallet_subaddress(_wallet!);
|
|
peya.Subaddress_setLabel(
|
|
subaddress,
|
|
accountIndex: accountIndex,
|
|
addressIndex: addressIndex,
|
|
label: label,
|
|
);
|
|
peya.Subaddress_refresh(
|
|
subaddress,
|
|
accountIndex: accountIndex,
|
|
label: '',
|
|
);
|
|
_storeWallet(reason: 'subaddress-label');
|
|
}
|
|
|
|
@override
|
|
Future<int> getBalance() async {
|
|
_ensureWallet();
|
|
return _loadWalletBalances().total;
|
|
}
|
|
|
|
@override
|
|
Future<int> getUnlockedBalance() async {
|
|
_ensureWallet();
|
|
return _loadWalletBalances().unlocked;
|
|
}
|
|
|
|
@override
|
|
Future<String> getSeed() async {
|
|
_ensureWallet();
|
|
final seed = peya.Wallet_seed(_wallet!, seedOffset: '');
|
|
if (seed.isEmpty) {
|
|
final error = peya.Wallet_errorString(_wallet!);
|
|
throw StateError(error.isEmpty ? 'Seed not available' : error);
|
|
}
|
|
return seed;
|
|
}
|
|
|
|
@override
|
|
Future<List<WalletTransaction>> getTransactions() async {
|
|
_ensureWallet();
|
|
if (_walletInitialized) {
|
|
_ensureStoreTxInfoEnabled();
|
|
}
|
|
final walletAddress = _wallet!.address;
|
|
final libPath = peya.libPath;
|
|
final result = await Isolate.run(
|
|
() => _loadTransactionsJob(walletAddress, libPath),
|
|
);
|
|
_maybeStoreOnHistoryUpdate(result);
|
|
return result;
|
|
}
|
|
|
|
@override
|
|
Future<bool> isConnected() async {
|
|
if (_wallet == null || _wallet == nullptr || !_walletInitialized) {
|
|
return false;
|
|
}
|
|
return peya.Wallet_connected(_wallet!) != 0;
|
|
}
|
|
|
|
@override
|
|
Future<void> connectToNode(NodeConfig nodeConfig) async {
|
|
_ensureWallet();
|
|
final trusted = nodeConfig.mode == NodeMode.remote
|
|
? (nodeConfig.activeRemote?.trusted ?? false)
|
|
: true;
|
|
final daemonConfig = _daemonAddress(nodeConfig);
|
|
_logger.i(
|
|
'Connecting to daemon: ${daemonConfig.address} (ssl=${daemonConfig.useSsl})');
|
|
peya.WalletManager_setDaemonAddress(_manager!, daemonConfig.address);
|
|
final initialized = peya.Wallet_init(
|
|
_wallet!,
|
|
daemonAddress: daemonConfig.address,
|
|
useSsl: daemonConfig.useSsl,
|
|
lightWallet: false,
|
|
proxyAddress: '',
|
|
);
|
|
if (!initialized) {
|
|
final error = peya.Wallet_errorString(_wallet!);
|
|
throw StateError(
|
|
error.isEmpty ? 'Failed to init wallet daemon connection' : error);
|
|
}
|
|
_walletInitialized = true;
|
|
peya.Wallet_setTrustedDaemon(_wallet!, arg: trusted);
|
|
_ensureStoreTxInfoEnabled();
|
|
_startBackgroundSync();
|
|
await _warmUpRefresh();
|
|
}
|
|
|
|
@override
|
|
Future<int> getNodeHeight() async {
|
|
_ensureWallet();
|
|
return peya.Wallet_daemonBlockChainHeight(_wallet!);
|
|
}
|
|
|
|
@override
|
|
Future<int> getWalletHeight() async {
|
|
_ensureWallet();
|
|
return peya.Wallet_blockChainHeight(_wallet!);
|
|
}
|
|
|
|
@override
|
|
Future<bool> isSynced() async {
|
|
_ensureWallet();
|
|
return peya.Wallet_synchronized(_wallet!);
|
|
}
|
|
|
|
@override
|
|
Future<double> getSyncProgress() async {
|
|
final nodeHeight = await getNodeHeight();
|
|
if (nodeHeight <= 0) {
|
|
return 0.0;
|
|
}
|
|
final walletHeight = await getWalletHeight();
|
|
return walletHeight / nodeHeight;
|
|
}
|
|
|
|
@override
|
|
Future<void> refresh() async {
|
|
_ensureWallet();
|
|
if (_rescanInProgress) {
|
|
return;
|
|
}
|
|
// Refresh local views to pick up latest state from the background refresh.
|
|
final walletAddress = _wallet!.address;
|
|
final libPath = peya.libPath;
|
|
await Isolate.run(() => _refreshJob(walletAddress, libPath));
|
|
if (_isSyncedForStore()) {
|
|
_storeWallet(reason: 'refresh');
|
|
}
|
|
}
|
|
|
|
Future<void> _warmUpRefresh() async {
|
|
_ensureWallet();
|
|
if (_rescanInProgress || !_walletInitialized) {
|
|
return;
|
|
}
|
|
final walletAddress = _wallet!.address;
|
|
final libPath = peya.libPath;
|
|
try {
|
|
await Isolate.run(() => _refreshWalletNowJob(walletAddress, libPath));
|
|
} catch (error) {
|
|
_logger.w('Warm-up refresh failed: $error');
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> rescanBlockchain({int? fromHeight}) async {
|
|
_ensureWallet();
|
|
if (!_walletInitialized) {
|
|
throw StateError('Wallet not connected to daemon');
|
|
}
|
|
_ensureStoreTxInfoEnabled();
|
|
if (fromHeight != null && fromHeight >= 0) {
|
|
peya.Wallet_setRefreshFromBlockHeight(
|
|
_wallet!,
|
|
refresh_from_block_height: fromHeight,
|
|
);
|
|
}
|
|
_rescanInProgress = true;
|
|
_stopBackgroundSync();
|
|
final walletAddress = _wallet!.address;
|
|
final libPath = peya.libPath;
|
|
var ok = false;
|
|
var status = 0;
|
|
var error = '';
|
|
try {
|
|
final result = await Isolate.run(() {
|
|
peya.libPath = libPath;
|
|
final ptr = Pointer<Void>.fromAddress(walletAddress);
|
|
final statusBefore = peya.Wallet_status(ptr);
|
|
final errorBefore = peya.Wallet_errorString(ptr);
|
|
final ok = peya.Wallet_rescanBlockchain(ptr);
|
|
final status = peya.Wallet_status(ptr);
|
|
final error = peya.Wallet_errorString(ptr);
|
|
return {
|
|
'statusBefore': statusBefore,
|
|
'errorBefore': errorBefore,
|
|
'ok': ok,
|
|
'status': status,
|
|
'error': error,
|
|
};
|
|
});
|
|
ok = result['ok'] as bool? ?? false;
|
|
status = result['status'] as int? ?? 0;
|
|
error = result['error'] as String? ?? '';
|
|
if (!ok) {
|
|
if (error.isEmpty && status == 0) {
|
|
peya.Wallet_rescanBlockchainAsync(_wallet!);
|
|
return;
|
|
}
|
|
final message = error.isEmpty
|
|
? 'Rescan failed (status=$status)'
|
|
: 'Rescan failed: $error';
|
|
throw StateError(message);
|
|
}
|
|
} finally {
|
|
_rescanInProgress = false;
|
|
_startBackgroundSync();
|
|
}
|
|
if (ok && _isSyncedForStore()) {
|
|
_storeWallet(reason: 'rescan-complete', force: true);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<SendPreview> prepareSend(SendRequest request) async {
|
|
_ensureWallet();
|
|
if (request.destinations.isEmpty) {
|
|
throw StateError('No destinations provided');
|
|
}
|
|
if (request.destinations.length > maxDestinations) {
|
|
throw StateError('Too many destinations (max $maxDestinations)');
|
|
}
|
|
if (request.sweepAll && request.destinations.length != 1) {
|
|
throw StateError('Sweep all requires a single destination');
|
|
}
|
|
for (final destination in request.destinations) {
|
|
final isValid = peya.Wallet_addressValid(destination.address, 0);
|
|
if (!isValid) {
|
|
throw StateError('Invalid destination address');
|
|
}
|
|
if (!request.sweepAll && destination.amountAtomic <= 0) {
|
|
throw StateError('Amount must be greater than 0');
|
|
}
|
|
}
|
|
_pendingTransaction = null;
|
|
|
|
peya.PendingTransaction pending = nullptr;
|
|
int? sweepAvailable;
|
|
if (request.destinations.length == 1 && request.sweepAll) {
|
|
final dest = request.destinations.first;
|
|
sweepAvailable = await getUnlockedBalance();
|
|
if (sweepAvailable <= 0) {
|
|
throw StateError('Insufficient balance');
|
|
}
|
|
pending = _createSweepAllTransaction(
|
|
dest.address,
|
|
paymentId: request.paymentId,
|
|
accountIndex: request.accountIndex,
|
|
);
|
|
var status = peya.PendingTransaction_status(pending);
|
|
var fee = status == 0 ? peya.PendingTransaction_fee(pending) : 0;
|
|
var amount = status == 0 ? peya.PendingTransaction_amount(pending) : 0;
|
|
var sweepAmount = sweepAvailable;
|
|
if (status != 0 || (fee == 0 && amount == 0)) {
|
|
for (var attempt = 0; attempt < 3; attempt++) {
|
|
pending = _createSingleTransaction(
|
|
dest.address,
|
|
sweepAmount,
|
|
paymentId: request.paymentId,
|
|
accountIndex: request.accountIndex,
|
|
);
|
|
status = peya.PendingTransaction_status(pending);
|
|
if (status != 0) {
|
|
final error = peya.PendingTransaction_errorString(pending);
|
|
if (_looksLikeInsufficientFunds(error) && attempt < 2) {
|
|
sweepAmount = _reduceSweepAmount(sweepAmount);
|
|
continue;
|
|
}
|
|
throw StateError(error.isEmpty
|
|
? 'Transaction preparation failed (status=$status)'
|
|
: error);
|
|
}
|
|
fee = peya.PendingTransaction_fee(pending);
|
|
amount = peya.PendingTransaction_amount(pending);
|
|
final nextAmount = sweepAvailable - fee;
|
|
if (nextAmount <= 0 || nextAmount == sweepAmount) {
|
|
break;
|
|
}
|
|
sweepAmount = nextAmount;
|
|
}
|
|
}
|
|
} else if (request.destinations.length == 1) {
|
|
final dest = request.destinations.first;
|
|
pending = _createSingleTransaction(
|
|
dest.address,
|
|
dest.amountAtomic,
|
|
paymentId: request.paymentId,
|
|
accountIndex: request.accountIndex,
|
|
);
|
|
} else {
|
|
final addresses =
|
|
request.destinations.map((dest) => dest.address).toList();
|
|
final amounts = request.sweepAll
|
|
? List.filled(request.destinations.length, 0)
|
|
: request.destinations.map((dest) => dest.amountAtomic).toList();
|
|
pending = peya.Wallet_createTransactionMultDest(
|
|
_wallet!,
|
|
dstAddr: addresses,
|
|
paymentId: request.paymentId,
|
|
isSweepAll: request.sweepAll,
|
|
amounts: amounts,
|
|
mixinCount: _defaultMixinCount,
|
|
pendingTransactionPriority: _defaultPriority,
|
|
subaddr_account: request.accountIndex,
|
|
);
|
|
}
|
|
|
|
if (pending == nullptr) {
|
|
final error = peya.Wallet_errorString(_wallet!);
|
|
throw StateError(error.isEmpty ? 'Failed to create transaction' : error);
|
|
}
|
|
|
|
var status = peya.PendingTransaction_status(pending);
|
|
if (status != 0) {
|
|
final error = peya.PendingTransaction_errorString(pending);
|
|
throw StateError(error.isEmpty
|
|
? 'Transaction preparation failed (status=$status)'
|
|
: error);
|
|
}
|
|
|
|
var fee = peya.PendingTransaction_fee(pending);
|
|
var amount = peya.PendingTransaction_amount(pending);
|
|
if (request.sweepAll && sweepAvailable != null) {
|
|
if (amount <= 0) {
|
|
amount = sweepAvailable - fee;
|
|
}
|
|
if (amount < 0) {
|
|
amount = 0;
|
|
}
|
|
final derivedFee =
|
|
sweepAvailable > amount ? (sweepAvailable - amount) : 0;
|
|
if (derivedFee > fee) {
|
|
fee = derivedFee;
|
|
}
|
|
} else if (amount <= 0) {
|
|
amount = request.destinations
|
|
.fold<int>(0, (sum, dest) => sum + dest.amountAtomic);
|
|
}
|
|
_pendingTransaction = pending;
|
|
|
|
return SendPreview(
|
|
amountAtomic: amount,
|
|
feeAtomic: fee,
|
|
destinationCount: request.destinations.length,
|
|
sweepAll: request.sweepAll,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<SendResult> commitPreparedSend() async {
|
|
final pending = _pendingTransaction;
|
|
if (pending == null || pending == nullptr) {
|
|
throw StateError('No pending transaction');
|
|
}
|
|
try {
|
|
final ok = peya.PendingTransaction_commit(
|
|
pending,
|
|
filename: '',
|
|
overwrite: false,
|
|
);
|
|
final status = peya.PendingTransaction_status(pending);
|
|
if (!ok || status != 0) {
|
|
final error = peya.PendingTransaction_errorString(pending);
|
|
throw StateError(
|
|
error.isEmpty ? 'Failed to commit transaction' : error);
|
|
}
|
|
final txidRaw =
|
|
peya.PendingTransaction_txid(pending, peya.defaultSeparatorStr);
|
|
var txIds = txidRaw
|
|
.split(peya.defaultSeparatorStr)
|
|
.map((id) => id.trim())
|
|
.where((id) => id.isNotEmpty)
|
|
.toList();
|
|
if (txIds.isEmpty) {
|
|
final fallback = _findRecentOutgoingTxIds(
|
|
maxCount: peya.PendingTransaction_txCount(pending));
|
|
if (fallback.isNotEmpty) {
|
|
txIds = fallback;
|
|
}
|
|
}
|
|
final amount = peya.PendingTransaction_amount(pending);
|
|
final fee = peya.PendingTransaction_fee(pending);
|
|
_storeWallet(reason: 'send', force: true);
|
|
return SendResult(
|
|
txIds: txIds,
|
|
amountAtomic: amount,
|
|
feeAtomic: fee,
|
|
);
|
|
} finally {
|
|
_pendingTransaction = null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> discardPreparedSend() async {
|
|
_pendingTransaction = null;
|
|
}
|
|
|
|
@override
|
|
Future<StakePreview> prepareStake({
|
|
required int amountAtomic,
|
|
int accountIndex = 0,
|
|
}) async {
|
|
_ensureWallet();
|
|
if (amountAtomic <= 0) {
|
|
throw StateError('Amount must be greater than 0');
|
|
}
|
|
final unlocked = await getUnlockedBalance();
|
|
if (amountAtomic > unlocked) {
|
|
throw StateError('Insufficient balance');
|
|
}
|
|
_pendingTransaction = null;
|
|
|
|
final pending = _createStakeTransaction(
|
|
amountAtomic,
|
|
accountIndex: accountIndex,
|
|
);
|
|
if (pending == nullptr) {
|
|
final error = peya.Wallet_errorString(_wallet!);
|
|
throw StateError(
|
|
error.isEmpty ? 'Failed to create stake transaction' : error,
|
|
);
|
|
}
|
|
|
|
final status = peya.PendingTransaction_status(pending);
|
|
if (status != 0) {
|
|
final error = peya.PendingTransaction_errorString(pending);
|
|
throw StateError(
|
|
error.isEmpty ? 'Stake preparation failed (status=$status)' : error);
|
|
}
|
|
|
|
final fee = peya.PendingTransaction_fee(pending);
|
|
final amount = peya.PendingTransaction_amount(pending);
|
|
_pendingTransaction = pending;
|
|
return StakePreview(
|
|
amountAtomic: amount > 0 ? amount : amountAtomic,
|
|
feeAtomic: fee,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<Map<String, StakeYieldInfo>> getActiveStakeYields({
|
|
required List<WalletTransaction> activeStakes,
|
|
required int currentHeight,
|
|
}) async {
|
|
_ensureWallet();
|
|
if (activeStakes.isEmpty || !_walletInitialized) {
|
|
return const {};
|
|
}
|
|
final walletAddress = _wallet!.address;
|
|
final libPath = peya.libPath;
|
|
final stakeInputs = activeStakes
|
|
.map((stakeTx) => <String, Object>{
|
|
'txid': stakeTx.txid,
|
|
'amountAtomic': stakeTx.amountAtomic,
|
|
'blockHeight': stakeTx.blockHeight,
|
|
'unlockHeight': effectiveStakeUnlockHeight(stakeTx),
|
|
})
|
|
.toList(growable: false);
|
|
return Isolate.run(
|
|
() => _loadActiveStakeYieldsJob(
|
|
walletAddress,
|
|
libPath,
|
|
currentHeight,
|
|
stakeInputs,
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<StakeResult> commitPreparedStake() async {
|
|
final pending = _pendingTransaction;
|
|
if (pending == null || pending == nullptr) {
|
|
throw StateError('No pending stake transaction');
|
|
}
|
|
try {
|
|
final ok = peya.PendingTransaction_commit(
|
|
pending,
|
|
filename: '',
|
|
overwrite: false,
|
|
);
|
|
final status = peya.PendingTransaction_status(pending);
|
|
if (!ok || status != 0) {
|
|
final error = peya.PendingTransaction_errorString(pending);
|
|
throw StateError(
|
|
error.isEmpty ? 'Failed to commit stake transaction' : error,
|
|
);
|
|
}
|
|
final txidRaw =
|
|
peya.PendingTransaction_txid(pending, peya.defaultSeparatorStr);
|
|
final txIds = txidRaw
|
|
.split(peya.defaultSeparatorStr)
|
|
.map((id) => id.trim())
|
|
.where((id) => id.isNotEmpty)
|
|
.toList();
|
|
final amount = peya.PendingTransaction_amount(pending);
|
|
final fee = peya.PendingTransaction_fee(pending);
|
|
_storeWallet(reason: 'stake', force: true);
|
|
return StakeResult(
|
|
txIds: txIds,
|
|
amountAtomic: amount,
|
|
feeAtomic: fee,
|
|
);
|
|
} finally {
|
|
_pendingTransaction = null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> discardPreparedStake() async {
|
|
_pendingTransaction = null;
|
|
}
|
|
|
|
void _ensureManager() {
|
|
if (_manager == null) {
|
|
throw StateError('WalletManager not initialized');
|
|
}
|
|
}
|
|
|
|
void _ensureWallet() {
|
|
if (_wallet == null || _wallet == nullptr) {
|
|
final error = _manager == null
|
|
? 'Wallet not opened'
|
|
: peya.WalletManager_errorString(_manager!);
|
|
throw StateError(error.isEmpty ? 'Wallet not opened' : error);
|
|
}
|
|
}
|
|
|
|
void _ensureWalletCreated() {
|
|
if (_wallet == null || _wallet == nullptr) {
|
|
final error = _manager == null
|
|
? 'Wallet not opened'
|
|
: peya.WalletManager_errorString(_manager!);
|
|
throw StateError(error.isEmpty ? 'Wallet not opened' : error);
|
|
}
|
|
}
|
|
|
|
void _startBackgroundSync() {
|
|
if (_wallet == null || _wallet == nullptr || !_walletInitialized) {
|
|
return;
|
|
}
|
|
peya.Wallet_setAutoRefreshInterval(_wallet!, millis: 10000);
|
|
peya.Wallet_refreshAsync(_wallet!);
|
|
peya.Wallet_startRefresh(_wallet!);
|
|
}
|
|
|
|
void _stopBackgroundSync() {
|
|
if (_wallet == null || _wallet == nullptr || !_walletInitialized) {
|
|
return;
|
|
}
|
|
peya.Wallet_pauseRefresh(_wallet!);
|
|
peya.Wallet_stop(_wallet!);
|
|
}
|
|
|
|
void _ensureStoreTxInfoEnabled() {
|
|
_ensureWallet();
|
|
try {
|
|
_storeTxInfoLib ??= DynamicLibrary.open(peya.libPath);
|
|
_setStoreTxInfo ??= _storeTxInfoLib!.lookupFunction<
|
|
_WalletSetStoreTxInfoNative, _WalletSetStoreTxInfoDart>(
|
|
'PEYA_Wallet_setStoreTxInfo',
|
|
);
|
|
_getStoreTxInfo ??= _storeTxInfoLib!.lookupFunction<
|
|
_WalletGetStoreTxInfoNative, _WalletGetStoreTxInfoDart>(
|
|
'PEYA_Wallet_getStoreTxInfo',
|
|
);
|
|
} catch (error) {
|
|
_logger.w('Store-tx-info support not available in this library: $error');
|
|
return;
|
|
}
|
|
final enabled = _getStoreTxInfo?.call(_wallet!) ?? 0;
|
|
if (enabled == 0) {
|
|
_setStoreTxInfo?.call(_wallet!, 1);
|
|
peya.Wallet_store(_wallet!, path: '');
|
|
}
|
|
}
|
|
|
|
void _storeWallet({required String reason, bool force = false}) {
|
|
if (_wallet == null || _wallet == nullptr) {
|
|
return;
|
|
}
|
|
if (_rescanInProgress && !force) {
|
|
return;
|
|
}
|
|
final now = DateTime.now();
|
|
if (!force &&
|
|
_lastStoreAt != null &&
|
|
now.difference(_lastStoreAt!) < _minStoreInterval) {
|
|
return;
|
|
}
|
|
final storePath = _walletPath;
|
|
final resolvedStorePath =
|
|
(storePath != null && !_isDirectoryPath(storePath)) ? storePath : '';
|
|
final ok = peya.Wallet_store(_wallet!, path: resolvedStorePath);
|
|
if (!ok) {
|
|
final error = peya.Wallet_errorString(_wallet!);
|
|
_logger.w(
|
|
'Wallet store failed ($reason): ${error.isEmpty ? 'unknown error' : error}');
|
|
return;
|
|
}
|
|
_lastStoreAt = now;
|
|
}
|
|
|
|
void _maybeStoreOnHistoryUpdate(List<WalletTransaction> transactions) {
|
|
if (_rescanInProgress) {
|
|
return;
|
|
}
|
|
if (!_isSyncedForStore()) {
|
|
return;
|
|
}
|
|
final fingerprint = _historyFingerprint(transactions);
|
|
if (_lastHistoryFingerprint == null) {
|
|
_lastHistoryFingerprint = fingerprint;
|
|
return;
|
|
}
|
|
if (_lastHistoryFingerprint != fingerprint) {
|
|
_lastHistoryFingerprint = fingerprint;
|
|
_storeWallet(reason: 'history-update');
|
|
}
|
|
}
|
|
|
|
String _historyFingerprint(List<WalletTransaction> transactions) {
|
|
if (transactions.isEmpty) {
|
|
return '0';
|
|
}
|
|
final first = transactions.first;
|
|
final last = transactions.length > 1 ? transactions.last : first;
|
|
final firstStamp = first.timestamp?.millisecondsSinceEpoch ?? 0;
|
|
final lastStamp = last.timestamp?.millisecondsSinceEpoch ?? 0;
|
|
return '${transactions.length}:${first.txid}:${first.blockHeight}:$firstStamp:'
|
|
'${last.txid}:${last.blockHeight}:$lastStamp';
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
String _walletKeysPath(String walletPath) {
|
|
return walletPath.endsWith('.keys') ? walletPath : '$walletPath.keys';
|
|
}
|
|
|
|
bool _isDirectoryPath(String path) {
|
|
if (path.endsWith(p.separator)) {
|
|
return true;
|
|
}
|
|
return Directory(path).existsSync();
|
|
}
|
|
|
|
bool _isSyncedForStore() {
|
|
if (_wallet == null || _wallet == nullptr) {
|
|
return false;
|
|
}
|
|
final nodeHeight = peya.Wallet_daemonBlockChainHeight(_wallet!);
|
|
final walletHeight = peya.Wallet_blockChainHeight(_wallet!);
|
|
if (nodeHeight <= 0) {
|
|
return peya.Wallet_synchronized(_wallet!);
|
|
}
|
|
return walletHeight >= nodeHeight - 1;
|
|
}
|
|
|
|
({String address, bool useSsl}) _daemonAddress(NodeConfig config) {
|
|
String host;
|
|
int port;
|
|
if (config.mode == NodeMode.local) {
|
|
host = 'localhost';
|
|
port = 17750;
|
|
} else {
|
|
final remote = config.activeRemote;
|
|
host = remote?.host ?? 'localhost';
|
|
port = remote?.port ?? 17750;
|
|
}
|
|
final hasScheme = host.contains('://');
|
|
final uri = hasScheme ? Uri.parse(host) : Uri.parse('http://$host');
|
|
final resolvedHost = uri.host.isEmpty ? host : uri.host;
|
|
final resolvedPort = uri.hasPort ? uri.port : port;
|
|
final scheme = hasScheme && uri.scheme.isNotEmpty ? uri.scheme : 'http';
|
|
final useSsl = scheme == 'https';
|
|
return (address: '$scheme://$resolvedHost:$resolvedPort', useSsl: useSsl);
|
|
}
|
|
|
|
List<String> _findRecentOutgoingTxIds({int maxCount = 1}) {
|
|
if (_wallet == null || _wallet == nullptr || maxCount <= 0) {
|
|
return const [];
|
|
}
|
|
try {
|
|
final history = peya.Wallet_history(_wallet!);
|
|
peya.TransactionHistory_refresh(history);
|
|
final count = peya.TransactionHistory_count(history);
|
|
final entries = <({String txid, int timestamp})>[];
|
|
for (var i = 0; i < count; i++) {
|
|
final info = peya.TransactionHistory_transaction(history, index: i);
|
|
if (info == nullptr) {
|
|
continue;
|
|
}
|
|
final direction = peya.TransactionInfo_direction(info);
|
|
if (direction != peya.TransactionInfo_Direction.Out) {
|
|
continue;
|
|
}
|
|
final txid = peya.TransactionInfo_hash(info);
|
|
if (txid.isEmpty) {
|
|
continue;
|
|
}
|
|
final fee = peya.TransactionInfo_fee(info);
|
|
if (fee <= 0) {
|
|
continue;
|
|
}
|
|
final timestamp = peya.TransactionInfo_timestamp(info);
|
|
entries.add((txid: txid, timestamp: timestamp));
|
|
}
|
|
entries.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
|
final seen = <String>{};
|
|
final result = <String>[];
|
|
for (final entry in entries) {
|
|
if (seen.add(entry.txid)) {
|
|
result.add(entry.txid);
|
|
}
|
|
if (result.length >= maxCount) {
|
|
break;
|
|
}
|
|
}
|
|
return result;
|
|
} catch (error) {
|
|
_logger.w('Failed to find recent outgoing txids: $error');
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
Map<int, _SubaddressBalance> _loadSubaddressBalances(int accountIndex) {
|
|
if (_wallet == null || _wallet == nullptr) {
|
|
return {};
|
|
}
|
|
final balances = <int, _SubaddressBalance>{};
|
|
final coins = peya.Wallet_coins(_wallet!);
|
|
peya.Coins_refresh(coins);
|
|
final count = peya.Coins_count(coins);
|
|
for (var i = 0; i < count; i++) {
|
|
final coin = peya.Coins_coin(coins, i);
|
|
if (coin == nullptr) {
|
|
continue;
|
|
}
|
|
if (peya.CoinsInfo_spent(coin)) {
|
|
continue;
|
|
}
|
|
final coinAccount = peya.CoinsInfo_subaddrAccount(coin);
|
|
if (coinAccount != accountIndex) {
|
|
continue;
|
|
}
|
|
final index = peya.CoinsInfo_subaddrIndex(coin);
|
|
final amount = peya.CoinsInfo_amount(coin);
|
|
if (amount <= 0) {
|
|
continue;
|
|
}
|
|
final entry = balances.putIfAbsent(index, () => _SubaddressBalance());
|
|
entry.total += amount;
|
|
if (peya.CoinsInfo_unlocked(coin)) {
|
|
entry.unlocked += amount;
|
|
}
|
|
}
|
|
return balances;
|
|
}
|
|
|
|
_SubaddressBalance _loadWalletBalances() {
|
|
if (_wallet == null || _wallet == nullptr) {
|
|
return _SubaddressBalance();
|
|
}
|
|
|
|
final totals = _SubaddressBalance();
|
|
final coins = peya.Wallet_coins(_wallet!);
|
|
peya.Coins_refresh(coins);
|
|
final count = peya.Coins_count(coins);
|
|
for (var i = 0; i < count; i++) {
|
|
final coin = peya.Coins_coin(coins, i);
|
|
if (coin == nullptr || peya.CoinsInfo_spent(coin)) {
|
|
continue;
|
|
}
|
|
final amount = peya.CoinsInfo_amount(coin);
|
|
if (amount <= 0) {
|
|
continue;
|
|
}
|
|
totals.total += amount;
|
|
if (peya.CoinsInfo_unlocked(coin)) {
|
|
totals.unlocked += amount;
|
|
}
|
|
}
|
|
return totals;
|
|
}
|
|
|
|
peya.PendingTransaction _createSingleTransaction(
|
|
String address,
|
|
int amountAtomic, {
|
|
required String paymentId,
|
|
required int accountIndex,
|
|
}) {
|
|
return peya.Wallet_createTransaction(
|
|
_wallet!,
|
|
dst_addr: address,
|
|
payment_id: paymentId,
|
|
amount: amountAtomic,
|
|
mixin_count: _defaultMixinCount,
|
|
pendingTransactionPriority: _defaultPriority,
|
|
subaddr_account: accountIndex,
|
|
);
|
|
}
|
|
|
|
peya.PendingTransaction _createSweepAllTransaction(
|
|
String address, {
|
|
required String paymentId,
|
|
required int accountIndex,
|
|
}) {
|
|
return peya.Wallet_createTransaction(
|
|
_wallet!,
|
|
dst_addr: address,
|
|
payment_id: paymentId,
|
|
amount: 0,
|
|
mixin_count: _defaultMixinCount,
|
|
pendingTransactionPriority: _defaultPriority,
|
|
subaddr_account: accountIndex,
|
|
);
|
|
}
|
|
|
|
peya.PendingTransaction _createStakeTransaction(
|
|
int amountAtomic, {
|
|
required int accountIndex,
|
|
}) {
|
|
final selfAddress = peya.Wallet_address(
|
|
_wallet!,
|
|
accountIndex: accountIndex,
|
|
addressIndex: 0,
|
|
);
|
|
final addressPtr = selfAddress.toNativeUtf8().cast<Char>();
|
|
final paymentIdPtr = ''.toNativeUtf8().cast<Char>();
|
|
final preferredInputsPtr = ''.toNativeUtf8().cast<Char>();
|
|
final separatorPtr = peya.defaultSeparatorStr.toNativeUtf8().cast<Char>();
|
|
_stakeLib ??= DynamicLibrary.open(peya.libPath);
|
|
final createStake = _stakeLib!.lookupFunction<
|
|
Pointer<Void> Function(
|
|
Pointer<Void>,
|
|
Pointer<Char>,
|
|
Pointer<Char>,
|
|
Uint64,
|
|
Uint32,
|
|
Int32,
|
|
Uint32,
|
|
Pointer<Char>,
|
|
Pointer<Char>,
|
|
),
|
|
Pointer<Void> Function(
|
|
Pointer<Void>,
|
|
Pointer<Char>,
|
|
Pointer<Char>,
|
|
int,
|
|
int,
|
|
int,
|
|
int,
|
|
Pointer<Char>,
|
|
Pointer<Char>,
|
|
)>('PEYA_Wallet_createStakeTransaction');
|
|
try {
|
|
return createStake(
|
|
_wallet!,
|
|
addressPtr,
|
|
paymentIdPtr,
|
|
amountAtomic,
|
|
_defaultMixinCount,
|
|
_defaultPriority,
|
|
accountIndex,
|
|
preferredInputsPtr,
|
|
separatorPtr,
|
|
);
|
|
} finally {
|
|
calloc.free(addressPtr);
|
|
calloc.free(paymentIdPtr);
|
|
calloc.free(preferredInputsPtr);
|
|
calloc.free(separatorPtr);
|
|
}
|
|
}
|
|
|
|
bool _looksLikeInsufficientFunds(String message) {
|
|
final normalized = message.toLowerCase();
|
|
return normalized.contains('not enough') ||
|
|
normalized.contains('insufficient') ||
|
|
normalized.contains('no funds') ||
|
|
normalized.contains('unknown error');
|
|
}
|
|
|
|
int _reduceSweepAmount(int amount) {
|
|
final reduction = (amount * 0.01).round();
|
|
final minReduction = 100000;
|
|
final next = amount - (reduction > minReduction ? reduction : minReduction);
|
|
return next > 0 ? next : 0;
|
|
}
|
|
|
|
Future<void> _runWalletInitInBackground(_WalletInitRequest request) async {
|
|
final result = await Isolate.run(
|
|
() => _walletInitJob(request.toMap()),
|
|
);
|
|
if (result.ok) {
|
|
return;
|
|
}
|
|
throw StateError(result.error ?? 'Wallet initialization failed');
|
|
}
|
|
}
|
|
|
|
@pragma('vm:entry-point')
|
|
void _refreshJob(int walletAddress, String libPath) {
|
|
peya.libPath = libPath;
|
|
final wallet = Pointer<Void>.fromAddress(walletAddress);
|
|
final history = peya.Wallet_history(wallet);
|
|
peya.TransactionHistory_refresh(history);
|
|
final coins = peya.Wallet_coins(wallet);
|
|
peya.Coins_refresh(coins);
|
|
}
|
|
|
|
@pragma('vm:entry-point')
|
|
void _refreshWalletNowJob(int walletAddress, String libPath) {
|
|
peya.libPath = libPath;
|
|
final wallet = Pointer<Void>.fromAddress(walletAddress);
|
|
peya.Wallet_refresh(wallet);
|
|
}
|
|
|
|
@pragma('vm:entry-point')
|
|
Map<String, StakeYieldInfo> _loadActiveStakeYieldsJob(
|
|
int walletAddress,
|
|
String libPath,
|
|
int currentHeight,
|
|
List<Map<String, Object>> stakeInputs,
|
|
) {
|
|
if (walletAddress == 0 || stakeInputs.isEmpty) {
|
|
return const {};
|
|
}
|
|
peya.libPath = libPath;
|
|
final wallet = Pointer<Void>.fromAddress(walletAddress);
|
|
final dylib = DynamicLibrary.open(libPath);
|
|
final getYieldInfoRaw = dylib.lookupFunction<_WalletGetYieldInfoRawNative,
|
|
_WalletGetYieldInfoRawDart>('PEYA_Wallet_getYieldInfoRaw');
|
|
final rawCount =
|
|
dylib.lookupFunction<_YieldInfoRawCountNative, _YieldInfoRawCountDart>(
|
|
'PEYA_YieldInfoRaw_count');
|
|
final rawBlockHeight = dylib.lookupFunction<_YieldInfoRawUint64FieldNative,
|
|
_YieldInfoRawUint64FieldDart>('PEYA_YieldInfoRaw_blockHeight');
|
|
final rawSlippage = dylib.lookupFunction<_YieldInfoRawUint64FieldNative,
|
|
_YieldInfoRawUint64FieldDart>('PEYA_YieldInfoRaw_slippageTotalThisBlock');
|
|
final rawLockedTally = dylib.lookupFunction<_YieldInfoRawUint64FieldNative,
|
|
_YieldInfoRawUint64FieldDart>('PEYA_YieldInfoRaw_lockedCoinsTally');
|
|
final rawFree =
|
|
dylib.lookupFunction<_YieldInfoRawFreeNative, _YieldInfoRawFreeDart>(
|
|
'PEYA_YieldInfoRaw_free');
|
|
|
|
final rawPtr = getYieldInfoRaw(wallet);
|
|
if (rawPtr == nullptr) {
|
|
return const {};
|
|
}
|
|
|
|
final samples = <YieldBlockSample>[];
|
|
try {
|
|
final count = rawCount(rawPtr);
|
|
for (var index = 0; index < count; index++) {
|
|
final lockedCoinsTally = rawLockedTally(rawPtr, index);
|
|
final slippageTotalThisBlock = rawSlippage(rawPtr, index);
|
|
if (lockedCoinsTally <= 0 || slippageTotalThisBlock <= 0) {
|
|
continue;
|
|
}
|
|
samples.add(
|
|
YieldBlockSample(
|
|
blockHeight: rawBlockHeight(rawPtr, index),
|
|
slippageTotalThisBlock: slippageTotalThisBlock,
|
|
lockedCoinsTally: lockedCoinsTally,
|
|
),
|
|
);
|
|
}
|
|
} finally {
|
|
rawFree(rawPtr);
|
|
}
|
|
|
|
final result = <String, StakeYieldInfo>{};
|
|
for (final stakeInput in stakeInputs) {
|
|
final txid = stakeInput['txid'] as String;
|
|
result[txid] = computeAccruedStakeYieldForRange(
|
|
stakeAmountAtomic: stakeInput['amountAtomic'] as int,
|
|
stakeStartHeight: stakeInput['blockHeight'] as int,
|
|
stakeUnlockHeight: stakeInput['unlockHeight'] as int,
|
|
currentHeight: currentHeight,
|
|
samples: samples,
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
@pragma('vm:entry-point')
|
|
List<WalletTransaction> _loadTransactionsJob(
|
|
int walletAddress,
|
|
String libPath,
|
|
) {
|
|
peya.libPath = libPath;
|
|
final wallet = Pointer<Void>.fromAddress(walletAddress);
|
|
final selfAddresses = <String>{};
|
|
final selfOutputSumByTxid = <String, int>{};
|
|
final selfHasNonPrimaryByTxid = <String, bool>{};
|
|
try {
|
|
final coins = peya.Wallet_coins(wallet);
|
|
peya.Coins_refresh(coins);
|
|
final coinCount = peya.Coins_getAll_size(coins);
|
|
for (var i = 0; i < coinCount; i++) {
|
|
final coin = peya.Coins_getAll_byIndex(coins, i);
|
|
if (coin == nullptr) {
|
|
continue;
|
|
}
|
|
final coinAddress = peya.CoinsInfo_address(coin);
|
|
if (coinAddress.isNotEmpty) {
|
|
selfAddresses.add(coinAddress);
|
|
}
|
|
final txid = peya.CoinsInfo_hash(coin);
|
|
if (txid.isEmpty) {
|
|
continue;
|
|
}
|
|
final amount = peya.CoinsInfo_amount(coin);
|
|
if (amount <= 0) {
|
|
continue;
|
|
}
|
|
selfOutputSumByTxid[txid] = (selfOutputSumByTxid[txid] ?? 0) + amount;
|
|
final subaddrIndex = peya.CoinsInfo_subaddrIndex(coin);
|
|
if (subaddrIndex > 0) {
|
|
selfHasNonPrimaryByTxid[txid] = true;
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
try {
|
|
final subaddress = peya.Wallet_subaddress(wallet);
|
|
peya.Subaddress_refresh(subaddress, accountIndex: 0, label: '');
|
|
final subCount = peya.Subaddress_getAll_size(subaddress);
|
|
for (var i = 0; i < subCount; i++) {
|
|
final row = peya.Subaddress_getAll_byIndex(subaddress, index: i);
|
|
if (row == nullptr) {
|
|
continue;
|
|
}
|
|
final address = peya.SubaddressRow_getAddress(row);
|
|
if (address.isNotEmpty) {
|
|
selfAddresses.add(address);
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
final primary = peya.Wallet_address(wallet, accountIndex: 0, addressIndex: 0);
|
|
if (primary.isNotEmpty) {
|
|
selfAddresses.add(primary);
|
|
}
|
|
final history = peya.Wallet_history(wallet);
|
|
|
|
peya.TransactionHistory_refresh(history);
|
|
final count = peya.TransactionHistory_count(history);
|
|
final incomingSumByTxid = <String, int>{};
|
|
for (var i = 0; i < count; i++) {
|
|
final info = peya.TransactionHistory_transaction(history, index: i);
|
|
if (info == nullptr) {
|
|
continue;
|
|
}
|
|
final direction = peya.TransactionInfo_direction(info) ==
|
|
peya.TransactionInfo_Direction.In
|
|
? TransactionDirection.incoming
|
|
: TransactionDirection.outgoing;
|
|
if (direction != TransactionDirection.incoming) {
|
|
continue;
|
|
}
|
|
final txid = peya.TransactionInfo_hash(info);
|
|
if (txid.isEmpty) {
|
|
continue;
|
|
}
|
|
final amount = peya.TransactionInfo_amount(info);
|
|
if (amount <= 0) {
|
|
continue;
|
|
}
|
|
incomingSumByTxid[txid] = (incomingSumByTxid[txid] ?? 0) + amount;
|
|
}
|
|
|
|
final transactions = <WalletTransaction>[];
|
|
for (var i = 0; i < count; i++) {
|
|
final info = peya.TransactionHistory_transaction(history, index: i);
|
|
if (info == nullptr) {
|
|
continue;
|
|
}
|
|
final direction = peya.TransactionInfo_direction(info) ==
|
|
peya.TransactionInfo_Direction.In
|
|
? TransactionDirection.incoming
|
|
: TransactionDirection.outgoing;
|
|
final rawType = peya.TransactionInfo_type(info);
|
|
final type =
|
|
_mapTransactionType(rawType, peya.TransactionInfo_isCoinbase(info));
|
|
final timestamp = peya.TransactionInfo_timestamp(info);
|
|
final dt = timestamp > 0
|
|
? DateTime.fromMillisecondsSinceEpoch(timestamp * 1000)
|
|
: null;
|
|
final isPending = peya.TransactionInfo_isPending(info);
|
|
final isFailed = peya.TransactionInfo_isFailed(info);
|
|
final feeAtomic = peya.TransactionInfo_fee(info);
|
|
final confirmations = peya.TransactionInfo_confirmations(info);
|
|
final blockHeight = peya.TransactionInfo_blockHeight(info);
|
|
final unlockTime = peya.TransactionInfo_unlockTime(info);
|
|
var amountAtomic = peya.TransactionInfo_amount(info);
|
|
final txid = peya.TransactionInfo_hash(info);
|
|
final asset = peya.TransactionInfo_asset(info);
|
|
final transfersCount = peya.TransactionInfo_transfers_count(info);
|
|
final returnAddresses = <String>[];
|
|
final returnCount = peya.TransactionInfo_returnAddresses_count(info);
|
|
for (var returnIndex = 0; returnIndex < returnCount; returnIndex++) {
|
|
final address =
|
|
peya.TransactionInfo_returnAddresses_at(info, returnIndex);
|
|
if (address.isNotEmpty) {
|
|
returnAddresses.add(address);
|
|
}
|
|
}
|
|
var transferSum = 0;
|
|
var allTransfersToSelf = transfersCount > 0;
|
|
String? counterpartyAddress;
|
|
for (var transferIndex = 0;
|
|
transferIndex < transfersCount;
|
|
transferIndex++) {
|
|
transferSum += peya.TransactionInfo_transfers_amount(info, transferIndex);
|
|
final address =
|
|
peya.TransactionInfo_transfers_address(info, transferIndex);
|
|
if (address.isEmpty || !selfAddresses.contains(address)) {
|
|
allTransfersToSelf = false;
|
|
counterpartyAddress ??= address.isEmpty ? null : address;
|
|
}
|
|
}
|
|
var isSelfTransfer = false;
|
|
if (direction == TransactionDirection.outgoing &&
|
|
allTransfersToSelf &&
|
|
transferSum > 0) {
|
|
isSelfTransfer = true;
|
|
amountAtomic = transferSum;
|
|
}
|
|
if (direction == TransactionDirection.outgoing &&
|
|
!isPending &&
|
|
amountAtomic == 0) {
|
|
final selfOutputSum = txid.isEmpty ? 0 : (selfOutputSumByTxid[txid] ?? 0);
|
|
final hasNonPrimary =
|
|
txid.isNotEmpty && (selfHasNonPrimaryByTxid[txid] ?? false);
|
|
if (hasNonPrimary && selfOutputSum > 0) {
|
|
amountAtomic = selfOutputSum;
|
|
isSelfTransfer = true;
|
|
}
|
|
}
|
|
if (direction == TransactionDirection.outgoing &&
|
|
!isSelfTransfer &&
|
|
!isPending &&
|
|
amountAtomic == 0) {
|
|
final incomingSum = txid.isEmpty ? 0 : (incomingSumByTxid[txid] ?? 0);
|
|
if (incomingSum > 0) {
|
|
amountAtomic = incomingSum;
|
|
isSelfTransfer = true;
|
|
}
|
|
}
|
|
transactions.add(
|
|
WalletTransaction(
|
|
txid: txid,
|
|
direction: direction,
|
|
type: type,
|
|
amountAtomic: amountAtomic,
|
|
isSelfTransfer: isSelfTransfer,
|
|
asset: asset.isEmpty ? 'PEY' : asset,
|
|
timestamp: dt,
|
|
confirmations: confirmations,
|
|
blockHeight: blockHeight,
|
|
unlockTime: unlockTime,
|
|
isPending: isPending,
|
|
isFailed: isFailed,
|
|
feeAtomic: feeAtomic,
|
|
counterpartyAddress: counterpartyAddress,
|
|
returnAddresses: returnAddresses,
|
|
),
|
|
);
|
|
}
|
|
transactions.sort((a, b) {
|
|
final at = a.timestamp?.millisecondsSinceEpoch ?? 0;
|
|
final bt = b.timestamp?.millisecondsSinceEpoch ?? 0;
|
|
return bt.compareTo(at);
|
|
});
|
|
return transactions;
|
|
}
|
|
|
|
TransactionType _mapTransactionType(int rawType, bool isCoinbase) {
|
|
if (rawType == 2) {
|
|
return TransactionType.protocol;
|
|
}
|
|
if (isCoinbase) {
|
|
return TransactionType.miner;
|
|
}
|
|
switch (rawType) {
|
|
case 1:
|
|
return TransactionType.miner;
|
|
case 3:
|
|
return TransactionType.transfer;
|
|
case 4:
|
|
return TransactionType.convert;
|
|
case 5:
|
|
return TransactionType.burn;
|
|
case 6:
|
|
return TransactionType.stake;
|
|
case 7:
|
|
return TransactionType.txReturn;
|
|
case 8:
|
|
return TransactionType.audit;
|
|
default:
|
|
return TransactionType.unset;
|
|
}
|
|
}
|
|
|
|
@pragma('vm:entry-point')
|
|
Future<_WalletInitResult> _walletInitJob(Map<String, dynamic> raw) async {
|
|
try {
|
|
final request = _WalletInitRequest.fromMap(raw);
|
|
peya.libPath = request.libPath;
|
|
final manager = peya.WalletManagerFactory_getWalletManager();
|
|
if (manager == nullptr) {
|
|
return const _WalletInitResult(
|
|
ok: false,
|
|
error: 'Failed to initialize Peya WalletManager',
|
|
);
|
|
}
|
|
peya.wallet? wallet;
|
|
switch (request.action) {
|
|
case _WalletInitAction.create:
|
|
wallet = peya.WalletManager_createWallet(
|
|
manager,
|
|
path: request.path,
|
|
password: request.password,
|
|
language: 'English',
|
|
networkType: 0,
|
|
);
|
|
case _WalletInitAction.restore:
|
|
wallet = peya.WalletManager_recoveryWallet(
|
|
manager,
|
|
path: request.path,
|
|
password: request.password,
|
|
mnemonic: request.seed,
|
|
networkType: 0,
|
|
restoreHeight: 0,
|
|
kdfRounds: 0,
|
|
seedOffset: '',
|
|
);
|
|
}
|
|
if (wallet == nullptr) {
|
|
final error = peya.WalletManager_errorString(manager);
|
|
return _WalletInitResult(
|
|
ok: false,
|
|
error: error.isEmpty ? 'Failed to initialize wallet' : error,
|
|
);
|
|
}
|
|
peya.WalletManager_closeWallet(manager, wallet, true);
|
|
return const _WalletInitResult(ok: true);
|
|
} catch (error) {
|
|
return _WalletInitResult(ok: false, error: error.toString());
|
|
}
|
|
}
|
|
|
|
enum _WalletInitAction { create, restore }
|
|
|
|
class _WalletInitRequest {
|
|
const _WalletInitRequest({
|
|
required this.action,
|
|
required this.libPath,
|
|
required this.path,
|
|
required this.password,
|
|
required this.seed,
|
|
});
|
|
|
|
final _WalletInitAction action;
|
|
final String libPath;
|
|
final String path;
|
|
final String password;
|
|
final String seed;
|
|
|
|
factory _WalletInitRequest.create({
|
|
required String libPath,
|
|
required String path,
|
|
required String password,
|
|
}) {
|
|
return _WalletInitRequest(
|
|
action: _WalletInitAction.create,
|
|
libPath: libPath,
|
|
path: path,
|
|
password: password,
|
|
seed: '',
|
|
);
|
|
}
|
|
|
|
factory _WalletInitRequest.restore({
|
|
required String libPath,
|
|
required String path,
|
|
required String password,
|
|
required String seed,
|
|
}) {
|
|
return _WalletInitRequest(
|
|
action: _WalletInitAction.restore,
|
|
libPath: libPath,
|
|
path: path,
|
|
password: password,
|
|
seed: seed,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toMap() {
|
|
return {
|
|
'action': action.name,
|
|
'libPath': libPath,
|
|
'path': path,
|
|
'password': password,
|
|
'seed': seed,
|
|
};
|
|
}
|
|
|
|
factory _WalletInitRequest.fromMap(Map<String, dynamic> map) {
|
|
return _WalletInitRequest(
|
|
action: _WalletInitAction.values.firstWhere(
|
|
(value) => value.name == map['action'],
|
|
orElse: () => _WalletInitAction.create,
|
|
),
|
|
libPath: map['libPath'] as String,
|
|
path: map['path'] as String,
|
|
password: map['password'] as String,
|
|
seed: map['seed'] as String,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _WalletInitResult {
|
|
const _WalletInitResult({required this.ok, this.error});
|
|
|
|
final bool ok;
|
|
final String? error;
|
|
}
|