Files
peyawallet/lib/native/monero_c_backend.dart
Codex Bot c7550386a2
build / Build Linux wallet (push) Failing after 1m48s
build / Build Windows wallet (push) Has been cancelled
Warm up wallet refresh after node connect
2026-04-19 21:20:04 +02:00

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;
}