From e5bbd32873b07e2db3596737b0c50f9393b70cb6 Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 8 Jan 2026 11:03:30 -0600 Subject: [PATCH 01/17] firo restore and refresh optimizations --- .../cached_electrumx_client.dart | 63 +++++- lib/electrumx_rpc/electrumx_client.dart | 22 ++ lib/wallets/wallet/impl/firo_wallet.dart | 193 +++++++++++------- .../spark_interface.dart | 49 ++++- 4 files changed, 245 insertions(+), 82 deletions(-) diff --git a/lib/electrumx_rpc/cached_electrumx_client.dart b/lib/electrumx_rpc/cached_electrumx_client.dart index 7c23af4010..e1b2235015 100644 --- a/lib/electrumx_rpc/cached_electrumx_client.dart +++ b/lib/electrumx_rpc/cached_electrumx_client.dart @@ -26,15 +26,13 @@ class CachedElectrumXClient { required ElectrumXClient electrumXClient, }) => CachedElectrumXClient(electrumXClient: electrumXClient); - String base64ToHex(String source) => - base64Decode( - LineSplitter.split(source).join(), - ).map((e) => e.toRadixString(16).padLeft(2, '0')).join(); + String base64ToHex(String source) => base64Decode( + LineSplitter.split(source).join(), + ).map((e) => e.toRadixString(16).padLeft(2, '0')).join(); - String base64ToReverseHex(String source) => - base64Decode( - LineSplitter.split(source).join(), - ).reversed.map((e) => e.toRadixString(16).padLeft(2, '0')).join(); + String base64ToReverseHex(String source) => base64Decode( + LineSplitter.split(source).join(), + ).reversed.map((e) => e.toRadixString(16).padLeft(2, '0')).join(); /// Call electrumx getTransaction on a per coin basis, storing the result in local db if not already there. /// @@ -77,6 +75,55 @@ class CachedElectrumXClient { } } + Future>> getBatchTransactions({ + required List txHashes, + required CryptoCurrency cryptoCurrency, + }) async { + try { + final box = await DB.instance.getTxCacheBox(currency: cryptoCurrency); + + final List> result = []; + final List needsFetching = []; + + for (final txHash in txHashes) { + final cachedTx = box.get(txHash) as Map?; + if (cachedTx == null) { + needsFetching.add(txHash); + } else { + result.add(Map.from(cachedTx)); + } + } + + if (needsFetching.isNotEmpty) { + final txns = await electrumXClient.getBatchTransactions( + txHashes: needsFetching, + ); + + for (final tx in txns) { + tx.remove("hex"); + tx.remove("lelantusData"); + tx.remove("sparkData"); + + if (tx["confirmations"] != null && + tx["confirmations"] as int > minCacheConfirms) { + await box.put(tx["txid"] as String, tx); + } + + result.add(tx); + } + } + + return result; + } catch (e, s) { + Logging.instance.e( + "Failed to process CachedElectrumX.getTransaction(): ", + error: e, + stackTrace: s, + ); + rethrow; + } + } + /// Clear all cached transactions for the specified coin Future clearSharedTransactionCache({ required CryptoCurrency cryptoCurrency, diff --git a/lib/electrumx_rpc/electrumx_client.dart b/lib/electrumx_rpc/electrumx_client.dart index 2ef2791cbe..e38e73afc3 100644 --- a/lib/electrumx_rpc/electrumx_client.dart +++ b/lib/electrumx_rpc/electrumx_client.dart @@ -828,6 +828,28 @@ class ElectrumXClient { return Map.from(response as Map); } + Future>> getBatchTransactions({ + required List txHashes, + String? requestID, + }) async { + Logging.instance.d( + "attempting to fetch BATCHED blockchain.transaction.get...", + ); + + final response = await batchRequest( + command: 'blockchain.transaction.get', + args: txHashes.map((e) => [e, true]).toList(), + ); + final List> result = []; + for (int i = 0; i < response.length; i++) { + result.add(Map.from(response[i] as Map)); + } + + Logging.instance.d("Fetching blockchain.transaction.get BATCHED finished"); + + return result; + } + /// Returns the whole Lelantus anonymity set for denomination in the groupId. /// /// ex: diff --git a/lib/wallets/wallet/impl/firo_wallet.dart b/lib/wallets/wallet/impl/firo_wallet.dart index bd2b3f70f7..701f4a41c8 100644 --- a/lib/wallets/wallet/impl/firo_wallet.dart +++ b/lib/wallets/wallet/impl/firo_wallet.dart @@ -93,10 +93,28 @@ class FiroWallet extends Bip39HDWallet final allAddressesSet = {...receivingAddresses, ...changeAddresses}; - final List> allTxHashes = await fetchHistory( + Logging.instance.d( + "firo_wallet.dart updateTransactions() allAddressesSet.length: " + "${allAddressesSet.length}", + ); + + final List> allTxHashes1 = await fetchHistory( allAddressesSet, ); + Logging.instance.d( + "firo_wallet.dart updateTransactions() allTxHashes.length: " + "${allTxHashes1.length}", + ); + + final Map> allHistory = {}; + + for (final item in allTxHashes1) { + final txid = item["tx_hash"] as String; + allHistory[txid] ??= {}; + allHistory[txid]!["height"] ??= item["height"] as int?; + } + final sparkCoins = await mainDB.isar.sparkCoins .where() .walletIdEqualToAnyLTagHash(walletId) @@ -111,85 +129,118 @@ class FiroWallet extends Bip39HDWallet .walletIdEqualTo(walletId) .filter() .heightIsNull() + .txidProperty() .findAll(); - for (final tx in unconfirmedTransactions) { - final txn = await electrumXCachedClient.getTransaction( - txHash: tx.txid, - verbose: true, - cryptoCurrency: info.coin, - ); - final height = txn["height"] as int?; - - if (height != null) { - // tx was mined - // add to allTxHashes - final info = {"tx_hash": tx.txid, "height": height}; - allTxHashes.add(info); + for (final txid in unconfirmedTransactions) { + if (allHistory[txid] == null) { + allHistory[txid] = {}; } } final Set sparkTxids = {}; for (final coin in sparkCoins) { sparkTxids.add(coin.txHash); - // check for duplicates before adding to list - if (allTxHashes.indexWhere((e) => e["tx_hash"] == coin.txHash) == -1) { - final info = {"tx_hash": coin.txHash, "height": coin.height}; - allTxHashes.add(info); + if (allHistory[coin.txHash] == null) { + allHistory[coin.txHash] = {"height": coin.height}; } } final missing = await getSparkSpendTransactionIds(); for (final txid in missing.map((e) => e.txid).toSet()) { - // check for duplicates before adding to list - if (allTxHashes.indexWhere((e) => e["tx_hash"] == txid) == -1) { - final info = {"tx_hash": txid}; - allTxHashes.add(info); + if (allHistory[txid] == null) { + allHistory[txid] = {}; } } - final currentHeight = await chainHeight; - - for (final txHash in allTxHashes) { - final storedTx = await mainDB.isar.transactionV2s - .where() - .walletIdEqualTo(walletId) - .filter() - .txidEqualTo(txHash["tx_hash"] as String) - .findFirst(); - - if (storedTx?.isConfirmed( - currentHeight, - cryptoCurrency.minConfirms, - cryptoCurrency.minCoinbaseConfirms, - ) == - true) { - // tx already confirmed, no need to process it again - continue; - } + final confirmedTxidsInIsar = await mainDB.isar.transactionV2s + .where() + .walletIdEqualTo(walletId) + .filter() + .heightIsNotNull() + .and() + .heightGreaterThan(1) + .txidProperty() + .findAll(); - // firod/electrumx seem to take forever to process spark txns so we'll - // just ignore null errors and check again on next refresh. - // This could also be a bug in the custom electrumx rpc code - final Map tx; - try { - tx = await electrumXCachedClient.getTransaction( - txHash: txHash["tx_hash"] as String, - verbose: true, - cryptoCurrency: info.coin, - ); - } catch (_) { - continue; - } + Logging.instance.d( + "firo_wallet.dart updateTransactions() confirmedTxidsInIsar.length: " + "${confirmedTxidsInIsar.length}", + ); + + // assume every tx that has a height is confirmed and remove them from the + // list of transactions to fetch and check. This should be fine in firo. + confirmedTxidsInIsar.forEach(allHistory.remove); + + final allTxids = allHistory.keys.toList(growable: false); - // check for duplicates before adding to list - if (allTransactions.indexWhere( - (e) => e["txid"] == tx["txid"] as String, - ) == - -1) { - tx["height"] ??= txHash["height"]; + const batchSize = 100; + final remainder = allTxids.length % batchSize; + final batchCount = allTxids.length ~/ batchSize; + + for (int i = 0; i < batchCount; i++) { + final start = i * batchSize; + final end = start + batchSize; + Logging.instance.i("[allTxids]: Fetching batch #$i"); + final txns = await electrumXCachedClient.getBatchTransactions( + txHashes: allTxids.sublist(start, end), + cryptoCurrency: cryptoCurrency, + ); + for (final tx in txns) { + tx["height"] ??= allHistory[tx["txid"]]!["height"]; allTransactions.add(tx); } } + // handle remainder + if (remainder > 0) { + final txns = await electrumXCachedClient.getBatchTransactions( + txHashes: allTxids.sublist(allTxids.length - remainder), + cryptoCurrency: cryptoCurrency, + ); + for (final tx in txns) { + tx["height"] ??= allHistory[tx["txid"]]!["height"]; + allTransactions.add(tx); + } + } + + final Set txInputTxidsSet = {}; + for (final txData in allTransactions) { + for (final jsonInput in txData["vin"] as List) { + final map = Map.from(jsonInput as Map); + final coinbase = map["coinbase"] as String?; + + final txid = map["txid"] as String?; + final vout = map["vout"] as int?; + if (coinbase == null && txid != null && vout != null) { + txInputTxidsSet.add(txid); + } + } + } + final txInputTxids = txInputTxidsSet.toList(growable: false); + + final Map> someInputTxns = {}; + final remainder2 = txInputTxids.length % batchSize; + for (int i = 0; i < txInputTxids.length ~/ batchSize; i++) { + final start = i * batchSize; + final end = start + batchSize; + Logging.instance.i("[txInputTxids]: Fetching batch #$i"); + final txns = await electrumXCachedClient.getBatchTransactions( + txHashes: txInputTxids.sublist(start, end), + cryptoCurrency: cryptoCurrency, + ); + for (final tx in txns) { + someInputTxns[tx["txid"] as String] = tx; + } + } + // handle remainder + if (remainder2 > 0) { + final txns = await electrumXCachedClient.getBatchTransactions( + txHashes: txInputTxids.sublist(txInputTxids.length - remainder2), + cryptoCurrency: cryptoCurrency, + ); + for (final tx in txns) { + someInputTxns[tx["txid"] as String] = tx; + } + } final List txns = []; @@ -225,14 +276,16 @@ class FiroWallet extends Bip39HDWallet if (isMySpark && sparkCoinsInvolvedReceived.isEmpty && !isMySpentSpark) { Logging.instance.e( - "sparkCoinsInvolvedReceived is empty and should not be! (ignoring tx parsing)", + "sparkCoinsInvolvedReceived is empty and should not be!" + " (ignoring tx parsing)", ); continue; } if (isMySpentSpark && sparkCoinsInvolvedSpent.isEmpty && !isMySpark) { Logging.instance.e( - "sparkCoinsInvolvedSpent is empty and should not be! (ignoring tx parsing)", + "sparkCoinsInvolvedSpent is empty and should not be!" + " (ignoring tx parsing)", ); continue; } @@ -250,7 +303,8 @@ class FiroWallet extends Bip39HDWallet isMint = true; } else { Logging.instance.d( - "Unknown mint op code found for lelantusmint tx: ${txData["txid"]}", + "Unknown mint op code found for lelantusmint tx: " + "${txData["txid"]}", ); } } else { @@ -268,7 +322,8 @@ class FiroWallet extends Bip39HDWallet isSparkMint = true; } else { Logging.instance.d( - "Unknown mint op code found for sparkmint tx: ${txData["txid"]}", + "Unknown mint op code found for sparkmint tx: " + "${txData["txid"]}", ); } } else { @@ -431,10 +486,8 @@ class FiroWallet extends Bip39HDWallet anonFees = anonFees! + fees; } } else if (coinbase == null && txid != null && vout != null) { - final inputTx = await electrumXCachedClient.getTransaction( - txHash: txid, - cryptoCurrency: cryptoCurrency, - ); + // fetched earlier so ! unwrap should be ok + final inputTx = someInputTxns[txid]!; final prevOutJson = Map.from( (inputTx["vout"] as List).firstWhere((e) => e["n"] == vout) as Map, @@ -623,8 +676,8 @@ class FiroWallet extends Bip39HDWallet String? label; if (jsonUTXO["value"] is int) { - // TODO: [prio=high] use special electrumx call to verify the 1000 Firo output is masternode - // electrumx call should exist now. Unsure if it works though + // verify the 1000 Firo output is masternode + // Fall back to locked in case network call fails blocked = Amount.fromDecimal( Decimal.fromInt( diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart index 5a2a025e16..d759107317 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart @@ -1209,16 +1209,57 @@ mixin SparkInterface ); } + Logging.instance.d( + "refreshSparkData() coinsToCheck.length: " + "${coinsToCheck.length}", + ); + + // prepare data for next step + final coinsToCheckTxids = coinsToCheck + .where((e) => e.height == null) + .map((e) => e.txHash) + .toList(growable: false); + + final Map> coinsToCheckTransactions = {}; + if (coinsToCheckTxids.isNotEmpty) { + const batchSize = 100; + final remainder = coinsToCheckTxids.length % batchSize; + final batchCount = coinsToCheckTxids.length ~/ batchSize; + + for (int i = 0; i < batchCount; i++) { + final start = i * batchSize; + final end = start + batchSize; + Logging.instance.i("[coinsToCheck]: Fetching batch #$i"); + final txns = await electrumXCachedClient.getBatchTransactions( + txHashes: coinsToCheckTxids.sublist(start, end), + cryptoCurrency: cryptoCurrency, + ); + for (final tx in txns) { + coinsToCheckTransactions[tx["txid"] as String] = tx; + } + } + // handle remainder + if (remainder > 0) { + final txns = await electrumXCachedClient.getBatchTransactions( + txHashes: coinsToCheckTxids.sublist( + coinsToCheckTxids.length - remainder, + ), + cryptoCurrency: cryptoCurrency, + ); + for (final tx in txns) { + coinsToCheckTransactions[tx["txid"] as String] = tx; + } + } + } + // check and update coins if required final List checkedCoins = []; for (final coin in coinsToCheck) { final SparkCoin checked; if (coin.height == null) { - final tx = await electrumXCachedClient.getTransaction( - txHash: coin.txHash, - cryptoCurrency: info.coin, - ); + final tx = coinsToCheckTransactions[coin.txHash]!; + if (tx["height"] is int) { checked = coin.copyWith( height: tx["height"] as int, From 964d2dd14447270e2873a7edc079166abc79e13f Mon Sep 17 00:00:00 2001 From: julian Date: Mon, 12 Jan 2026 10:26:53 -0600 Subject: [PATCH 02/17] add tooltip option to app bar icon button --- .../custom_buttons/app_bar_icon_button.dart | 58 ++++++++++--------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/lib/widgets/custom_buttons/app_bar_icon_button.dart b/lib/widgets/custom_buttons/app_bar_icon_button.dart index 5147f132d7..3041668fbc 100644 --- a/lib/widgets/custom_buttons/app_bar_icon_button.dart +++ b/lib/widgets/custom_buttons/app_bar_icon_button.dart @@ -14,6 +14,7 @@ import 'package:flutter_svg/svg.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/util.dart'; +import '../conditional_parent.dart'; class AppBarIconButton extends StatelessWidget { const AppBarIconButton({ @@ -25,6 +26,7 @@ class AppBarIconButton extends StatelessWidget { this.size = 36.0, this.shadows = const [], this.semanticsLabel = "Button", + this.tooltip, }); final Widget icon; @@ -34,29 +36,35 @@ class AppBarIconButton extends StatelessWidget { final double size; final List shadows; final String semanticsLabel; + final String? tooltip; @override Widget build(BuildContext context) { - return Container( - height: size, - width: size, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(1000), - color: color ?? Theme.of(context).extension()!.background, - boxShadow: shadows, - ), - child: Semantics( - excludeSemantics: true, - label: semanticsLabel, - child: MaterialButton( - splashColor: Theme.of(context).extension()!.highlight, - padding: EdgeInsets.zero, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(1000), + return ConditionalParent( + condition: tooltip != null, + builder: (child) => Tooltip(message: tooltip, child: child), + child: Container( + height: size, + width: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(1000), + color: + color ?? Theme.of(context).extension()!.background, + boxShadow: shadows, + ), + child: Semantics( + excludeSemantics: true, + label: semanticsLabel, + child: MaterialButton( + splashColor: Theme.of(context).extension()!.highlight, + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(1000), + ), + onPressed: onPressed, + child: icon, ), - onPressed: onPressed, - child: icon, ), ), ); @@ -84,18 +92,16 @@ class AppBarBackButton extends StatelessWidget { final isDesktop = Util.isDesktop; return Padding( padding: isDesktop - ? const EdgeInsets.symmetric( - vertical: 20, - horizontal: 24, - ) + ? const EdgeInsets.symmetric(vertical: 20, horizontal: 24) : const EdgeInsets.all(10), child: AppBarIconButton( semanticsLabel: semanticsLabel, - size: size ?? + size: + size ?? (isDesktop ? isCompact - ? 42 - : 56 + ? 42 + : 56 : 32), color: isDesktop ? Theme.of(context).extension()!.textFieldDefaultBG From fd0ea21802d88f735a3ce1b3403ed6399a116192 Mon Sep 17 00:00:00 2001 From: julian Date: Mon, 12 Jan 2026 10:27:50 -0600 Subject: [PATCH 03/17] paginate desktop recent activity transactions --- .../tx_v2/transaction_v2_list.dart | 204 +++++++++--------- .../tx_v2/transaction_v2_list_item.dart | 15 +- lib/widgets/paginated_list_view.dart | 177 +++++++++++++++ 3 files changed, 283 insertions(+), 113 deletions(-) create mode 100644 lib/widgets/paginated_list_view.dart diff --git a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart index 9acb8b7051..fc4269f084 100644 --- a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart +++ b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list.dart @@ -18,11 +18,12 @@ import '../../../../models/isar/models/blockchain_data/transaction.dart'; import '../../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../../../providers/db/main_db_provider.dart'; import '../../../../providers/global/wallets_provider.dart'; -import '../../../../themes/stack_colors.dart'; import '../../../../utilities/constants.dart'; import '../../../../utilities/util.dart'; import '../../../../wallets/crypto_currency/crypto_currency.dart'; +import '../../../../wallets/wallet/wallet_mixin_interfaces/cash_fusion_interface.dart'; import '../../../../widgets/loading_indicator.dart'; +import '../../../../widgets/paginated_list_view.dart'; import '../../sub_widgets/no_transactions_found.dart'; import '../../wallet_view.dart'; import 'fusion_tx_group_card.dart'; @@ -59,6 +60,54 @@ class _TransactionsV2ListState extends ConsumerState { ); } + List _processData(List transactions) { + if (ref.read(pWallets).getWallet(widget.walletId) is! CashFusionInterface) { + return transactions; + } + + final List processed = []; + + List fusions = []; + + for (int i = 0; i < transactions.length; i++) { + final tx = transactions[i]; + + if (tx.subType == TransactionSubType.cashFusion) { + if (fusions.isNotEmpty) { + final prevTime = DateTime.fromMillisecondsSinceEpoch( + fusions.last.timestamp * 1000, + ); + final thisTime = DateTime.fromMillisecondsSinceEpoch( + tx.timestamp * 1000, + ); + + if (prevTime.difference(thisTime).inMinutes > 30) { + processed.add(FusionTxGroup(fusions)); + fusions = [tx]; + continue; + } + } + + fusions.add(tx); + } + + if (i + 1 < transactions.length) { + final nextTx = transactions[i + 1]; + if (nextTx.subType != TransactionSubType.cashFusion && + fusions.isNotEmpty) { + processed.add(FusionTxGroup(fusions)); + fusions = []; + } + } + + if (tx.subType != TransactionSubType.cashFusion) { + processed.add(tx); + } + } + + return processed; + } + @override void initState() { coin = ref.read(pWallets).getWallet(widget.walletId).info.coin; @@ -73,11 +122,10 @@ class _TransactionsV2ListState extends ConsumerState { value: [widget.walletId], ), ], - filter: - ref - .read(pWallets) - .getWallet(widget.walletId) - .transactionFilterOperation, + filter: ref + .read(pWallets) + .getWallet(widget.walletId) + .transactionFilterOperation, sortBy: [const SortProperty(property: "timestamp", sort: Sort.desc)], ); @@ -128,110 +176,58 @@ class _TransactionsV2ListState extends ConsumerState { return compare; }); - final List _txns = []; - - List fusions = []; - - for (int i = 0; i < _transactions.length; i++) { - final tx = _transactions[i]; - - if (tx.subType == TransactionSubType.cashFusion) { - if (fusions.isNotEmpty) { - final prevTime = DateTime.fromMillisecondsSinceEpoch( - fusions.last.timestamp * 1000, - ); - final thisTime = DateTime.fromMillisecondsSinceEpoch( - tx.timestamp * 1000, - ); - - if (prevTime.difference(thisTime).inMinutes > 30) { - _txns.add(FusionTxGroup(fusions)); - fusions = [tx]; - continue; - } - } - - fusions.add(tx); - } - - if (i + 1 < _transactions.length) { - final nextTx = _transactions[i + 1]; - if (nextTx.subType != TransactionSubType.cashFusion && - fusions.isNotEmpty) { - _txns.add(FusionTxGroup(fusions)); - fusions = []; - } - } - - if (tx.subType != TransactionSubType.cashFusion) { - _txns.add(tx); - } - } + final _txns = _processData(_transactions); return RefreshIndicator( onRefresh: () async { await ref.read(pWallets).getWallet(widget.walletId).refresh(); }, - child: - Util.isDesktop - ? ListView.separated( - shrinkWrap: true, - itemBuilder: (context, index) { - BorderRadius? radius; - if (_txns.length == 1) { - radius = BorderRadius.circular( - Constants.size.circularBorderRadius, - ); - } else if (index == _txns.length - 1) { - radius = _borderRadiusLast; - } else if (index == 0) { - radius = _borderRadiusFirst; - } - final tx = _txns[index]; - return TxListItem(tx: tx, coin: coin, radius: radius); - }, - separatorBuilder: (context, index) { - return Container( - width: double.infinity, - height: 2, - color: - Theme.of( - context, - ).extension()!.background, + child: Util.isDesktop + ? PaginatedListView( + items: _txns, + itemBuilder: (context, tx, position) { + final radius = switch (position) { + PageItemPosition.first => _borderRadiusFirst, + PageItemPosition.last => _borderRadiusLast, + PageItemPosition.solo => BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + PageItemPosition.somewhere => null, + }; + + return TxListItem(tx: tx, coin: coin, radius: radius); + }, + ) + : ListView.builder( + itemCount: _txns.length, + itemBuilder: (context, index) { + BorderRadius? radius; + bool shouldWrap = false; + if (_txns.length == 1) { + radius = BorderRadius.circular( + Constants.size.circularBorderRadius, + ); + } else if (index == _txns.length - 1) { + radius = _borderRadiusLast; + shouldWrap = true; + } else if (index == 0) { + radius = _borderRadiusFirst; + } + final tx = _txns[index]; + if (shouldWrap) { + return Column( + children: [ + TxListItem(tx: tx, coin: coin, radius: radius), + const SizedBox( + height: WalletView.navBarHeight + 14, + ), + ], ); - }, - itemCount: _txns.length, - ) - : ListView.builder( - itemCount: _txns.length, - itemBuilder: (context, index) { - BorderRadius? radius; - bool shouldWrap = false; - if (_txns.length == 1) { - radius = BorderRadius.circular( - Constants.size.circularBorderRadius, - ); - } else if (index == _txns.length - 1) { - radius = _borderRadiusLast; - shouldWrap = true; - } else if (index == 0) { - radius = _borderRadiusFirst; - } - final tx = _txns[index]; - if (shouldWrap) { - return Column( - children: [ - TxListItem(tx: tx, coin: coin, radius: radius), - const SizedBox( - height: WalletView.navBarHeight + 14, - ), - ], - ); - } else { - return TxListItem(tx: tx, coin: coin, radius: radius); - } - }, - ), + } else { + return TxListItem(tx: tx, coin: coin, radius: radius); + } + }, + ), ); } }, diff --git a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list_item.dart b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list_item.dart index a9d0dee503..74ac48de21 100644 --- a/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list_item.dart +++ b/lib/pages/wallet_view/transaction_views/tx_v2/transaction_v2_list_item.dart @@ -49,6 +49,8 @@ class TxListItem extends ConsumerWidget { ) : []; + final txKeyString = _tx.txid + _tx.type.name + _tx.hashCode.toString(); + if (matchingTrades.isNotEmpty) { final trade = matchingTrades.first; return Container( @@ -60,14 +62,9 @@ class TxListItem extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - TransactionCardV2(key: UniqueKey(), transaction: _tx), + TransactionCardV2(key: Key(txKeyString), transaction: _tx), TradeCard( - key: Key( - _tx.txid + - _tx.type.name + - _tx.hashCode.toString() + - trade.uuid, - ), // + key: Key(txKeyString + trade.uuid), trade: trade, onTap: () async { if (Util.isDesktop) { @@ -160,7 +157,7 @@ class TxListItem extends ConsumerWidget { child: Breathing( child: TransactionCardV2( // this may mess with combined firo transactions - key: UniqueKey(), + key: Key(txKeyString), transaction: _tx, ), ), @@ -176,7 +173,7 @@ class TxListItem extends ConsumerWidget { borderRadius: radius, ), child: Breathing( - child: FusionTxGroupCard(key: UniqueKey(), group: group), + child: FusionTxGroupCard(key: ObjectKey(group), group: group), ), ); } diff --git a/lib/widgets/paginated_list_view.dart b/lib/widgets/paginated_list_view.dart new file mode 100644 index 0000000000..f4c0a32dfe --- /dev/null +++ b/lib/widgets/paginated_list_view.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../themes/stack_colors.dart'; +import '../utilities/assets.dart'; +import 'custom_buttons/app_bar_icon_button.dart'; + +enum PageItemPosition { first, last, solo, somewhere } + +class PaginatedListView extends StatefulWidget { + final List items; + final Widget Function(BuildContext context, T item, PageItemPosition position) + itemBuilder; + final int itemsPerPage; + final EdgeInsetsGeometry? padding; + + const PaginatedListView({ + super.key, + required this.items, + required this.itemBuilder, + this.itemsPerPage = 50, + this.padding, + }); + + @override + State> createState() => _PaginatedListViewState(); +} + +class _PaginatedListViewState extends State> { + int _currentPage = 0; + late int _totalPages; + late List _currentPageItems; + + void _updatePagination() { + _totalPages = (widget.items.length / widget.itemsPerPage).ceil(); + if (_totalPages == 0) _totalPages = 1; + + if (_currentPage >= _totalPages) { + _currentPage = _totalPages - 1; + } + + _updateCurrentPageItems(); + } + + void _updateCurrentPageItems() { + final startIndex = _currentPage * widget.itemsPerPage; + final endIndex = (startIndex + widget.itemsPerPage).clamp( + 0, + widget.items.length, + ); + _currentPageItems = widget.items.sublist(startIndex, endIndex); + } + + void _goToPage(int page) { + if (mounted && page >= 0 && page < _totalPages && page != _currentPage) { + setState(() { + _currentPage = page; + _updateCurrentPageItems(); + }); + } + } + + void _nextPage() => _goToPage(_currentPage + 1); + void _previousPage() => _goToPage(_currentPage - 1); + void _firstPage() => _goToPage(0); + void _lastPage() => _goToPage(_totalPages - 1); + + @override + void initState() { + super.initState(); + _updatePagination(); + } + + @override + void didUpdateWidget(PaginatedListView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.items != widget.items || + oldWidget.itemsPerPage != widget.itemsPerPage) { + _updatePagination(); + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded( + child: ListView.separated( + itemCount: _currentPageItems.length, + separatorBuilder: (context, index) { + return Container( + width: double.infinity, + height: 2, + color: Theme.of(context).extension()!.background, + ); + }, + itemBuilder: (context, index) { + final PageItemPosition position; + if (_currentPageItems.length == 1) { + position = .solo; + } else if (index == _currentPageItems.length - 1) { + position = .last; + } else if (index == 0) { + position = .first; + } else { + position = .somewhere; + } + + return widget.itemBuilder( + context, + _currentPageItems[index], + position, + ); + }, + ), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: .center, + children: [ + IconButton( + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, + disabledColor: Theme.of( + context, + ).extension()!.topNavIconPrimary.withAlpha(100), + icon: const Icon(Icons.first_page), + onPressed: _currentPage > 0 ? _firstPage : null, + tooltip: "First page", + ), + AppBarIconButton( + icon: Transform.flip( + flipX: true, + child: SvgPicture.asset( + Assets.svg.chevronRight, + width: 24, + height: 24, + color: Theme.of(context) + .extension()! + .topNavIconPrimary + .withAlpha(_currentPage > 0 ? 255 : 100), + ), + ), + tooltip: "Previous page", + onPressed: _currentPage > 0 ? _previousPage : null, + ), + AppBarIconButton( + icon: SvgPicture.asset( + Assets.svg.chevronRight, + width: 24, + height: 24, + color: Theme.of(context) + .extension()! + .topNavIconPrimary + .withAlpha(_currentPage < _totalPages - 1 ? 255 : 100), + ), + tooltip: "Next page", + onPressed: _currentPage < _totalPages - 1 ? _nextPage : null, + ), + IconButton( + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, + disabledColor: Theme.of( + context, + ).extension()!.topNavIconPrimary.withAlpha(100), + icon: const Icon(Icons.last_page), + onPressed: _currentPage < _totalPages - 1 ? _lastPage : null, + tooltip: "Last page", + ), + ], + ), + ], + ); + } +} From 4b9606eb8d34e19c3608a76c424740d7e823d58b Mon Sep 17 00:00:00 2001 From: julian Date: Mon, 12 Jan 2026 12:24:31 -0600 Subject: [PATCH 04/17] wrap xelis lib model to fix conditional import --- .../interfaces/lib_xelis_interface.dart | 14 ++++-- ...XEL_lib_xelis_interface_impl.template.dart | 44 ++++++++++++------- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/lib/wl_gen/interfaces/lib_xelis_interface.dart b/lib/wl_gen/interfaces/lib_xelis_interface.dart index 52d9079934..4674347e3b 100644 --- a/lib/wl_gen/interfaces/lib_xelis_interface.dart +++ b/lib/wl_gen/interfaces/lib_xelis_interface.dart @@ -1,7 +1,7 @@ import 'package:flutter/foundation.dart'; -import 'package:xelis_dart_sdk/src/data_transfer_objects/get_asset/max_supply_mode.dart'; - + import '../../providers/progress_report/xelis_table_progress_provider.dart'; +import '../../utilities/dynamic_object.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; export '../generated/lib_xelis_interface_impl.dart'; @@ -19,7 +19,10 @@ abstract class LibXelisInterface { Stream createProgressReportStream(); - bool isAddressValid({required String address, required CryptoCurrencyNetwork network}); + bool isAddressValid({ + required String address, + required CryptoCurrencyNetwork network, + }); bool validateSeedWord(String word); @@ -297,7 +300,10 @@ final class NewAsset extends Event { // final xelis_sdk.AssetData asset; final String name; final int decimals; - final MaxSupplyMode? maxSupply; + + // if used in later, this will probably need to be deconstructed in order + // to keep conditional import of xelis working + final DynamicObject? maxSupply; NewAsset(this.name, this.decimals, this.maxSupply); } diff --git a/tool/wl_templates/XEL_lib_xelis_interface_impl.template.dart b/tool/wl_templates/XEL_lib_xelis_interface_impl.template.dart index faccfbbfd9..fb485ff7f8 100644 --- a/tool/wl_templates/XEL_lib_xelis_interface_impl.template.dart +++ b/tool/wl_templates/XEL_lib_xelis_interface_impl.template.dart @@ -2,20 +2,21 @@ import 'dart:convert'; import 'package:logger/logger.dart'; +import 'package:xelis_dart_sdk/src/data_transfer_objects/get_asset/max_supply_mode.dart'; import 'package:xelis_dart_sdk/xelis_dart_sdk.dart' as xelis_sdk; import 'package:xelis_flutter/src/api/api.dart' as xelis_api; import 'package:xelis_flutter/src/api/logger.dart' as xelis_logging; +import 'package:xelis_flutter/src/api/models/wallet_dtos.dart' as x_wallet_dtos; import 'package:xelis_flutter/src/api/network.dart' as x_network; +import 'package:xelis_flutter/src/api/precomputed_tables.dart' as x_tables; +import 'package:xelis_flutter/src/api/progress_report.dart' as x_report; import 'package:xelis_flutter/src/api/seed_search_engine.dart' as x_seed; import 'package:xelis_flutter/src/api/utils.dart' as x_utils; import 'package:xelis_flutter/src/api/wallet.dart' as x_wallet; -import 'package:xelis_flutter/src/api/precomputed_tables.dart' as x_tables; -import 'package:xelis_flutter/src/api/models/wallet_dtos.dart' as x_wallet_dtos; -import 'package:xelis_flutter/src/api/progress_report.dart' as x_report; - import 'package:xelis_flutter/src/frb_generated.dart' as xelis_rust; import '../../providers/progress_report/xelis_table_progress_provider.dart'; +import '../../utilities/dynamic_object.dart'; import '../../utilities/logger.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; //END_ON @@ -83,14 +84,16 @@ final class _LibXelisInterfaceImpl extends LibXelisInterface { tableGeneration: (progress, step, message) { final currentStep = XelisTableGenerationStep.fromString(step); - final hasProgressJump = (progress - lastPrintedProgress).abs() >= 0.05; + final hasProgressJump = + (progress - lastPrintedProgress).abs() >= 0.05; final stepChanged = currentStep != lastStep; final isFinished = progress >= 0.99; if (hasProgressJump || stepChanged || isFinished) { final percent = (progress * 100).toStringAsFixed(1); - final extra = - (message != null && message.isNotEmpty) ? ' – $message' : ''; + final extra = (message != null && message.isNotEmpty) + ? ' – $message' + : ''; Logging.instance.d( 'Xelis Table Generation: $step - $percent%$extra', @@ -116,8 +119,13 @@ final class _LibXelisInterfaceImpl extends LibXelisInterface { } @override - bool isAddressValid({required String address, required CryptoCurrencyNetwork network}) => - x_utils.isAddressValid(strAddress: address, network: network.xelisNetwork); + bool isAddressValid({ + required String address, + required CryptoCurrencyNetwork network, + }) => x_utils.isAddressValid( + strAddress: address, + network: network.xelisNetwork, + ); @override bool validateSeedWord(String word) { @@ -144,7 +152,11 @@ final class _LibXelisInterfaceImpl extends LibXelisInterface { json['data'] as Map, ); - yield NewAsset(data.name, data.decimals, data.maxSupply); + yield NewAsset( + data.name, + data.decimals, + DynamicObject(data.maxSupply), + ); case xelis_sdk.WalletEvent.newTransaction: final tx = xelis_sdk.TransactionEntry.fromJson( json['data'] as Map, @@ -211,8 +223,8 @@ final class _LibXelisInterfaceImpl extends LibXelisInterface { // for now, just patching the old system into the new FFI API x_tables.PrecomputedTableType tableType = stack_l1Low - ? x_tables.PrecomputedTableType.l1Low() - : x_tables.PrecomputedTableType.l1Full(); + ? x_tables.PrecomputedTableType.l1Low() + : x_tables.PrecomputedTableType.l1Full(); return x_wallet.updateTables( precomputedTablesPath: precomputedTablesPath, @@ -239,8 +251,8 @@ final class _LibXelisInterfaceImpl extends LibXelisInterface { // for now, just patching the old system into the new FFI API x_tables.PrecomputedTableType tableType = stack_l1Low ?? false - ? x_tables.PrecomputedTableType.l1Low() - : x_tables.PrecomputedTableType.l1Full(); + ? x_tables.PrecomputedTableType.l1Low() + : x_tables.PrecomputedTableType.l1Full(); final wallet = await x_wallet.createXelisWallet( name: name, @@ -270,8 +282,8 @@ final class _LibXelisInterfaceImpl extends LibXelisInterface { // for now, just patching the old system into the new FFI API x_tables.PrecomputedTableType tableType = (stack_l1Low ?? false) - ? x_tables.PrecomputedTableType.l1Low() - : x_tables.PrecomputedTableType.l1Full(); + ? x_tables.PrecomputedTableType.l1Low() + : x_tables.PrecomputedTableType.l1Full(); final wallet = await x_wallet.openXelisWallet( name: name, From 6875b7a6edc7bd6326020f3d9402b1e158fc7b5f Mon Sep 17 00:00:00 2001 From: julian Date: Mon, 12 Jan 2026 12:29:54 -0600 Subject: [PATCH 05/17] https://github.com/cypherstack/stack_wallet/pull/1240/commits/22a1015e1d5c10117c57e5d52d32a8df418e1b45#diff-10e2e12cfe0c0a0a24e0f0512676f873f122344cf6eb81fc9f2655168c0468aaL76-L79 --- lib/pages/address_book_views/address_book_view.dart | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/pages/address_book_views/address_book_view.dart b/lib/pages/address_book_views/address_book_view.dart index 4a873cba66..d2fb198777 100644 --- a/lib/pages/address_book_views/address_book_view.dart +++ b/lib/pages/address_book_views/address_book_view.dart @@ -73,10 +73,7 @@ class _AddressBookViewState extends ConsumerState { } else { ref .read(addressBookFilterProvider) - .addAll( - coins.where((e) => e.network != CryptoCurrencyNetwork.test), - false, - ); + .addAll(coins.where((e) => !e.network.isTestNet), false); } } else { ref.read(addressBookFilterProvider).add(widget.coin!, false); From 2857755faf12ea76da233479eaaeb21fb9314cbd Mon Sep 17 00:00:00 2001 From: julian Date: Mon, 12 Jan 2026 12:32:30 -0600 Subject: [PATCH 06/17] update coinlib with firo related change --- scripts/app_config/templates/pubspec.template.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 16e9259dd3..6b551d4c48 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -220,7 +220,7 @@ dependencies: git: url: https://www.github.com/julian-CStack/coinlib path: coinlib_flutter - ref: f90600053a4f149a6153f30057ac7f75c21ab962 + ref: 5c59c7e7d120d9c981f23008fa03421d39fe8631 electrum_adapter: git: url: https://github.com/cypherstack/electrum_adapter.git @@ -318,7 +318,7 @@ dependency_overrides: git: url: https://www.github.com/julian-CStack/coinlib path: coinlib - ref: f90600053a4f149a6153f30057ac7f75c21ab962 + ref: 5c59c7e7d120d9c981f23008fa03421d39fe8631 bip47: git: From 65a8596a2b219260b7d5822e04abfb07fe641ec8 Mon Sep 17 00:00:00 2001 From: cassandras-lies <203535133+cassandras-lies@users.noreply.github.com> Date: Sun, 11 Jan 2026 11:08:13 +0000 Subject: [PATCH 07/17] Add support for creating Firo masternodes. --- .../masternodes/masternodes_home_view.dart | 842 ++++++++++++++++++ .../buy_spark_name_option_widget.dart | 2 +- lib/pages/wallet_view/wallet_view.dart | 22 + .../sub_widgets/desktop_wallet_features.dart | 11 + .../firo_desktop_wallet_summary.dart | 33 +- lib/route_generator.dart | 32 +- lib/wallets/models/tx_data.dart | 13 + lib/wallets/wallet/impl/firo_wallet.dart | 353 +++++++- lib/wallets/wallet/wallet.dart | 27 +- .../electrumx_interface.dart | 5 +- .../spark_interface.dart | 25 +- 11 files changed, 1313 insertions(+), 52 deletions(-) create mode 100644 lib/pages/masternodes/masternodes_home_view.dart diff --git a/lib/pages/masternodes/masternodes_home_view.dart b/lib/pages/masternodes/masternodes_home_view.dart new file mode 100644 index 0000000000..e0b5dbe424 --- /dev/null +++ b/lib/pages/masternodes/masternodes_home_view.dart @@ -0,0 +1,842 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../utilities/logger.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_app_bar.dart'; +import '../../widgets/desktop/desktop_scaffold.dart'; +import '../../widgets/stack_dialog.dart'; +import '../../providers/global/wallets_provider.dart'; +import '../../wallets/wallet/impl/firo_wallet.dart'; + +class MasternodesHomeView extends ConsumerStatefulWidget { + const MasternodesHomeView({super.key, required this.walletId}); + + final String walletId; + + static const String routeName = "/masternodesHomeView"; + + @override + ConsumerState createState() => + _MasternodesHomeViewState(); +} + +class _MasternodesHomeViewState extends ConsumerState { + late Future> _masternodesFuture; + + FiroWallet get _wallet => + ref.read(pWallets).getWallet(widget.walletId) as FiroWallet; + + @override + void initState() { + super.initState(); + _masternodesFuture = _wallet.getMyMasternodes(); + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + + return MasterScaffold( + isDesktop: isDesktop, + appBar: isDesktop + ? DesktopAppBar( + isCompactHeight: true, + background: Theme.of(context).extension()!.popupBG, + leading: Row( + children: [ + Padding( + padding: const EdgeInsets.only(left: 24, right: 20), + child: AppBarIconButton( + size: 32, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + shadows: const [], + icon: SvgPicture.asset( + Assets.svg.arrowLeft, + width: 18, + height: 18, + colorFilter: ColorFilter.mode( + Theme.of( + context, + ).extension()!.topNavIconPrimary, + BlendMode.srcIn, + ), + ), + onPressed: Navigator.of(context).pop, + ), + ), + SvgPicture.asset( + Assets.svg.robotHead, + width: 32, + height: 32, + colorFilter: ColorFilter.mode( + Theme.of(context).extension()!.textDark, + BlendMode.srcIn, + ), + ), + const SizedBox(width: 10), + Text("Masternodes", style: STextStyles.desktopH3(context)), + ], + ), + trailing: Padding( + padding: const EdgeInsets.only(right: 24), + child: ElevatedButton.icon( + onPressed: _showCreateMasternodeDialog, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of( + context, + ).extension()!.buttonBackPrimary, + foregroundColor: Theme.of( + context, + ).extension()!.buttonTextPrimary, + ), + icon: const Icon(Icons.add), + label: const Text('Create Masternode'), + ), + ), + ) + : AppBar( + leading: AppBarBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + titleSpacing: 0, + title: Text( + "Masternodes", + style: STextStyles.navBarTitle(context), + overflow: TextOverflow.ellipsis, + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 16), + child: IconButton( + onPressed: _showCreateMasternodeDialog, + icon: const Icon(Icons.add), + tooltip: 'Create Masternode', + ), + ), + ], + ), + body: _buildMasternodesTable(context), + ); + } + + Widget _buildMasternodesTable(BuildContext context) { + return FutureBuilder>( + future: _masternodesFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center( + child: Text( + "Failed to load masternodes", + style: STextStyles.w600_14(context), + ), + ); + } + final nodes = snapshot.data ?? const []; + if (nodes.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "No masternodes found", + style: STextStyles.w600_14(context), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _showCreateMasternodeDialog, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of( + context, + ).extension()!.buttonBackPrimary, + foregroundColor: Theme.of( + context, + ).extension()!.buttonTextPrimary, + ), + icon: const Icon(Icons.add), + label: const Text('Create Your First Masternode'), + ), + ], + ), + ); + } + + final isDesktop = Util.isDesktop; + final stack = Theme.of(context).extension()!; + + if (isDesktop) { + return _buildDesktopTable(nodes, stack); + } else { + return _buildMobileTable(nodes, stack); + } + }, + ); + } + + Widget _buildDesktopTable(List nodes, StackColors stack) { + return Container( + color: stack.textFieldDefaultBG, + child: Column( + children: [ + // Fixed header + Container( + height: 56, + color: stack.textFieldDefaultBG, + child: Row( + children: [ + const Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text('IP'), + ), + ), + ), + const Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text('Last Paid Height'), + ), + ), + ), + const Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text('Status'), + ), + ), + ), + Expanded(flex: 3, child: Container()), + ], + ), + ), + // Scrollable content + Expanded( + child: Container( + width: double.infinity, + color: stack.textFieldDefaultBG, + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Column( + children: nodes.map((node) { + final status = node.revocationReason == 0 + ? 'Active' + : 'Revoked'; + return SizedBox( + height: 48, + child: Row( + children: [ + Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Text( + node.serviceAddr, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Text( + node.lastPaidHeight.toString(), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: status.toLowerCase() == 'active' + ? stack.accentColorGreen + : stack.accentColorRed, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + status.toUpperCase(), + style: STextStyles.w600_12( + context, + ).copyWith(color: stack.textWhite), + ), + ), + ), + ), + ), + Expanded( + flex: 3, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () => + _showMasternodeInfoDialog(node), + icon: const Icon(Icons.info_outline), + tooltip: 'View Details', + ), + ], + ), + ), + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildMobileTable(List nodes, StackColors stack) { + return Container( + color: stack.textFieldDefaultBG, + child: ListView.separated( + padding: EdgeInsets.zero, + itemCount: nodes.length, + separatorBuilder: (_, __) => const SizedBox(height: 1), + itemBuilder: (context, index) { + final node = nodes[index]; + final status = node.revocationReason == 0 ? 'Active' : 'Revoked'; + + return Container( + width: double.infinity, + color: stack.textFieldDefaultBG, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'IP: ${node.serviceAddr}', + style: STextStyles.w600_14(context), + overflow: TextOverflow.ellipsis, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: status.toLowerCase() == 'active' + ? stack.accentColorGreen + : stack.accentColorRed, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + status.toUpperCase(), + style: STextStyles.w600_12( + context, + ).copyWith(color: stack.textWhite), + ), + ), + ], + ), + const SizedBox(height: 8), + _buildMobileRow( + 'Last Paid Height', + node.lastPaidHeight.toString(), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton.icon( + onPressed: () => _showMasternodeInfoDialog(node), + icon: const Icon(Icons.info_outline), + label: const Text('Details'), + style: OutlinedButton.styleFrom( + backgroundColor: stack.textFieldDefaultBG, + foregroundColor: stack.buttonTextSecondary, + side: BorderSide( + color: stack.buttonBackBorderSecondary, + ), + ), + ), + ], + ), + ], + ), + ); + }, + ), + ); + } + + Widget _buildMobileRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Align( + alignment: Alignment.centerLeft, + child: Text( + '$label:', + style: STextStyles.w500_12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ), + ), + ), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Text(value, style: STextStyles.w500_12(context)), + ), + ), + ], + ), + ); + } + + void _showCreateMasternodeDialog() { + showDialog( + context: context, + barrierDismissible: true, + builder: (context) => _CreateMasternodeDialog(wallet: _wallet), + ); + } + + void _showMasternodeInfoDialog(MasternodeInfo node) { + showDialog( + context: context, + barrierDismissible: true, + builder: (context) => _MasternodeInfoDialog(node: node), + ); + } +} + +class _CreateMasternodeDialog extends StatefulWidget { + const _CreateMasternodeDialog({required this.wallet}); + + final FiroWallet wallet; + + @override + State<_CreateMasternodeDialog> createState() => + _CreateMasternodeDialogState(); +} + +class _CreateMasternodeDialogState extends State<_CreateMasternodeDialog> { + final GlobalKey _formKey = GlobalKey(); + final TextEditingController _ipAndPortController = TextEditingController(); + final TextEditingController _operatorPubKeyController = + TextEditingController(); + final TextEditingController _votingAddressController = + TextEditingController(); + final TextEditingController _operatorRewardController = TextEditingController( + text: "0", + ); + final TextEditingController _payoutAddressController = + TextEditingController(); + bool _isRegistering = false; + String? _errorMessage; + + @override + void dispose() { + _ipAndPortController.dispose(); + _operatorPubKeyController.dispose(); + _votingAddressController.dispose(); + _operatorRewardController.dispose(); + _payoutAddressController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final stack = Theme.of(context).extension()!; + final spendable = widget.wallet.info.cachedBalance.spendable; + final spendableFiro = spendable.decimal; + final threshold = Decimal.fromInt(1000); + final canRegister = spendableFiro >= threshold; + final availableCount = (spendableFiro ~/ threshold).toInt(); + + return AlertDialog( + backgroundColor: stack.popupBG, + title: const Text('Create Masternode'), + content: SizedBox( + width: 500, + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!canRegister) + Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: stack.textFieldErrorBG, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Insufficient funds to register a masternode. You need at least 1000 public FIRO.', + style: STextStyles.w600_14( + context, + ).copyWith(color: stack.textDark), + ), + ) + else + Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: stack.textFieldSuccessBG, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'You can register $availableCount masternode(s).', + style: STextStyles.w600_14( + context, + ).copyWith(color: stack.textDark), + ), + ), + if (_errorMessage != null) + Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: stack.textFieldErrorBG, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Registration failed: $_errorMessage', + style: STextStyles.w600_14( + context, + ).copyWith(color: stack.textDark), + ), + ), + TextFormField( + controller: _ipAndPortController, + decoration: const InputDecoration( + labelText: 'IP:Port', + hintText: '123.45.67.89:8168', + ), + validator: (v) { + if (v == null || v.trim().isEmpty) return 'Required'; + final parts = v.split(':'); + if (parts.length != 2) return 'Format must be ip:port'; + if (int.tryParse(parts[1]) == null) return 'Invalid port'; + return null; + }, + ), + const SizedBox(height: 8), + TextFormField( + controller: _operatorPubKeyController, + decoration: const InputDecoration( + labelText: 'Operator public key (BLS)', + ), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + ), + const SizedBox(height: 8), + TextFormField( + controller: _votingAddressController, + decoration: const InputDecoration( + labelText: 'Voting address (optional)', + hintText: 'Defaults to owner address', + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _operatorRewardController, + decoration: const InputDecoration( + labelText: 'Operator reward (%)', + hintText: '0', + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 8), + TextFormField( + controller: _payoutAddressController, + decoration: const InputDecoration(labelText: 'Payout address'), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: _isRegistering ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _isRegistering || !canRegister + ? null + : _registerMasternode, + style: FilledButton.styleFrom( + backgroundColor: stack.buttonBackPrimary, + foregroundColor: stack.buttonTextPrimary, + ), + child: _isRegistering + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Create'), + ), + ], + ); + } + + Future _registerMasternode() async { + setState(() { + _isRegistering = true; + _errorMessage = null; // Clear any previous error + }); + + try { + final parts = _ipAndPortController.text.trim().split(':'); + final ip = parts[0]; + final port = int.parse(parts[1]); + final operatorPubKey = _operatorPubKeyController.text.trim(); + final votingAddress = _votingAddressController.text.trim(); + final operatorReward = _operatorRewardController.text.trim().isNotEmpty + ? (double.parse(_operatorRewardController.text.trim()) * 100).floor() + : 0; + final payoutAddress = _payoutAddressController.text.trim(); + + final txId = await widget.wallet.registerMasternode( + ip, + port, + operatorPubKey, + votingAddress, + operatorReward, + payoutAddress, + ); + + if (!mounted) return; + + // Get the parent navigator context before popping + final navigator = Navigator.of(context, rootNavigator: Util.isDesktop); + navigator.pop(); + + Logging.instance.i('Masternode registration submitted: $txId'); + + // Show success dialog after frame is complete to ensure navigation stack is correct + if (!mounted) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + showDialog( + context: context, + barrierDismissible: true, + useRootNavigator: Util.isDesktop, + builder: (_) => StackOkDialog( + title: 'Masternode Registration Submitted', + message: + 'Masternode registration submitted, your masternode will appear in the list after the tx is confirmed.\n\nTransaction ID: $txId', + desktopPopRootNavigator: Util.isDesktop, + ), + ); + }); + } catch (e, s) { + Logging.instance.e( + "Masternode registration failed", + error: e, + stackTrace: s, + ); + + if (!mounted) return; + + setState(() { + _errorMessage = e.toString(); + _isRegistering = false; + }); + } + } +} + +class _MasternodeInfoDialog extends StatelessWidget { + const _MasternodeInfoDialog({required this.node}); + + final MasternodeInfo node; + + @override + Widget build(BuildContext context) { + final stack = Theme.of(context).extension()!; + final status = node.revocationReason == 0 ? 'Active' : 'Revoked'; + + return AlertDialog( + backgroundColor: stack.popupBG, + title: const Text('Masternode Information'), + content: SizedBox( + width: 500, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildInfoRow(context, 'ProTx Hash', node.proTxHash), + _buildInfoRow( + context, + 'IP:Port', + '${node.serviceAddr}:${node.servicePort}', + ), + _buildInfoRow(context, 'Status', status), + _buildInfoRow( + context, + 'Registered Height', + node.registeredHeight.toString(), + ), + _buildInfoRow( + context, + 'Last Paid Height', + node.lastPaidHeight.toString(), + ), + _buildInfoRow(context, 'Payout Address', node.payoutAddress), + _buildInfoRow(context, 'Owner Address', node.ownerAddress), + _buildInfoRow(context, 'Voting Address', node.votingAddress), + _buildInfoRow( + context, + 'Operator Public Key', + node.pubKeyOperator, + ), + _buildInfoRow( + context, + 'Operator Reward', + '${node.operatorReward / 100} %', + ), + _buildInfoRow(context, 'Collateral Hash', node.collateralHash), + _buildInfoRow( + context, + 'Collateral Index', + node.collateralIndex.toString(), + ), + _buildInfoRow( + context, + 'Collateral Address', + node.collateralAddress, + ), + _buildInfoRow( + context, + 'Pose Penalty', + node.posePenalty.toString(), + ), + _buildInfoRow( + context, + 'Pose Revived Height', + node.poseRevivedHeight.toString(), + ), + _buildInfoRow( + context, + 'Pose Ban Height', + node.poseBanHeight.toString(), + ), + _buildInfoRow( + context, + 'Revocation Reason', + node.revocationReason.toString(), + ), + ], + ), + ), + ), + actions: [ + FilledButton( + onPressed: () => Navigator.of(context).pop(), + style: FilledButton.styleFrom( + backgroundColor: stack.buttonBackPrimary, + foregroundColor: stack.buttonTextPrimary, + ), + child: const Text('Close'), + ), + ], + ); + } + + Widget _buildInfoRow(BuildContext context, String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: STextStyles.w600_14(context).copyWith( + color: Theme.of(context).extension()!.textSubtitle1, + ), + ), + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + borderRadius: BorderRadius.circular(8), + ), + child: Text(value, style: STextStyles.w500_12(context)), + ), + ], + ), + ); + } +} diff --git a/lib/pages/spark_names/sub_widgets/buy_spark_name_option_widget.dart b/lib/pages/spark_names/sub_widgets/buy_spark_name_option_widget.dart index 7a2d17974a..6ccc76f4b3 100644 --- a/lib/pages/spark_names/sub_widgets/buy_spark_name_option_widget.dart +++ b/lib/pages/spark_names/sub_widgets/buy_spark_name_option_widget.dart @@ -47,7 +47,7 @@ class _BuySparkNameWidgetState extends ConsumerState { ref.read(pWallets).getWallet(widget.walletId) as SparkInterface; try { - await wallet.electrumXClient.getSparkNameData(sparkName: name); + await wallet.getSparkNameData(sparkName: name); // name exists return false; } catch (e) { diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index b3a3338791..ef61192919 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -106,6 +106,7 @@ import '../settings_views/wallet_settings_view/wallet_network_settings_view/wall import '../settings_views/wallet_settings_view/wallet_settings_view.dart'; import '../signing/signing_view.dart'; import '../spark_names/spark_names_home_view.dart'; +import '../masternodes/masternodes_home_view.dart'; import '../token_view/my_tokens_view.dart'; import 'sub_widgets/transactions_list.dart'; import 'sub_widgets/wallet_summary.dart'; @@ -1185,6 +1186,27 @@ class _WalletViewState extends ConsumerState { ); }, ), + if (!viewOnly && wallet is FiroWallet) + WalletNavigationBarItemData( + label: "Masternodes", + icon: SvgPicture.asset( + Assets.svg.recycle, + height: 20, + width: 20, + colorFilter: ColorFilter.mode( + Theme.of( + context, + ).extension()!.bottomNavIconIcon, + BlendMode.srcIn, + ), + ), + onTap: () { + Navigator.of(context).pushNamed( + MasternodesHomeView.routeName, + arguments: widget.walletId, + ); + }, + ), if (wallet is NamecoinWallet) WalletNavigationBarItemData( label: "Domains", diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart index e052df5513..81a9b839bd 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart @@ -27,6 +27,7 @@ import '../../../../pages/paynym/paynym_home_view.dart'; import '../../../../pages/salvium_stake/salvium_create_stake_view.dart'; import '../../../../pages/signing/signing_view.dart'; import '../../../../pages/spark_names/spark_names_home_view.dart'; +import '../../../../pages/masternodes/masternodes_home_view.dart'; import '../../../../providers/desktop/current_desktop_menu_item.dart'; import '../../../../providers/global/paynym_api_provider.dart'; import '../../../../providers/providers.dart'; @@ -92,6 +93,7 @@ enum WalletFeature { sparkNames("Names", "Spark names"), salviumStaking("Staking", "Staking"), sign("Sign/Verify", "Sign / Verify messages"), + masternodes("Masternodes", "Manage masternodes"), // special cases clearSparkCache("", ""), @@ -454,6 +456,12 @@ class _DesktopWalletFeaturesState extends ConsumerState { ); } + void _onMasternodesPressed() { + Navigator.of( + context, + ).pushNamed(MasternodesHomeView.routeName, arguments: widget.walletId); + } + List<(WalletFeature, String, FutureOr Function())> _getOptions( Wallet wallet, bool showExchange, @@ -496,6 +504,9 @@ class _DesktopWalletFeaturesState extends ConsumerState { if (wallet is SignVerifyInterface && !isViewOnly) (WalletFeature.sign, Assets.svg.pencil, _onSignPressed), + if (!isViewOnly && wallet is FiroWallet) + (WalletFeature.masternodes, Assets.svg.recycle, _onMasternodesPressed), + if (showCoinControl) ( WalletFeature.coinControl, diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/firo_desktop_wallet_summary.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/firo_desktop_wallet_summary.dart index 650f67f685..d1a51ba63b 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/firo_desktop_wallet_summary.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/firo_desktop_wallet_summary.dart @@ -55,6 +55,7 @@ class _WFiroDesktopWalletSummaryState void initState() { super.initState(); walletId = widget.walletId; + coin = ref.read(pWalletCoin(widget.walletId)) as Firo; } @@ -66,14 +67,13 @@ class _WFiroDesktopWalletSummaryState if (ref.watch( prefsChangeNotifierProvider.select((value) => value.externalCalls), )) { - price = - ref - .watch( - priceAnd24hChangeNotifierProvider.select( - (value) => value.getPrice(coin), - ), - ) - ?.value; + price = ref + .watch( + priceAnd24hChangeNotifierProvider.select( + (value) => value.getPrice(coin), + ), + ) + ?.value; } final _showAvailable = @@ -81,14 +81,16 @@ class _WFiroDesktopWalletSummaryState WalletBalanceToggleState.available; final balance0 = ref.watch(pWalletBalanceTertiary(walletId)); - final balanceToShowSpark = - _showAvailable ? balance0.spendable : balance0.total; + final balanceToShowSpark = _showAvailable + ? balance0.spendable + : balance0.total; final balance1 = ref.watch(pWalletBalanceSecondary(walletId)); final balance2 = ref.watch(pWalletBalance(walletId)); - final balanceToShowPublic = - _showAvailable ? balance2.spendable : balance2.total; + final balanceToShowPublic = _showAvailable + ? balance2.spendable + : balance2.total; return Consumer( builder: (context, ref, __) { @@ -168,10 +170,9 @@ class _Prefix extends StatelessWidget { SizedBox( width: 20, height: 20, - child: - asset.endsWith(".png") - ? Image(image: AssetImage(asset)) - : SvgPicture.asset(asset), + child: asset.endsWith(".png") + ? Image(image: AssetImage(asset)) + : SvgPicture.asset(asset), ), const SizedBox(width: 6), diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 35bf76bbb4..4961b23ae5 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -163,6 +163,7 @@ import 'pages/spark_names/buy_spark_name_view.dart'; import 'pages/spark_names/confirm_spark_name_transaction_view.dart'; import 'pages/spark_names/spark_names_home_view.dart'; import 'pages/spark_names/sub_widgets/spark_name_details.dart'; +import 'pages/masternodes/masternodes_home_view.dart'; import 'pages/special/firo_rescan_recovery_error_dialog.dart'; import 'pages/stack_privacy_calls.dart'; import 'pages/token_view/my_tokens_view.dart'; @@ -897,6 +898,16 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case MasternodesHomeView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => MasternodesHomeView(walletId: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case BuySparkNameView.routeName: if (args is ({String walletId, String name})) { return getRoute( @@ -1846,10 +1857,8 @@ class RouteGenerator { if (args is (String, String)) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => SolTokenSendView( - walletId: args.$1, - tokenMint: args.$2, - ), + builder: (_) => + SolTokenSendView(walletId: args.$1, tokenMint: args.$2), settings: RouteSettings(name: settings.name), ); } @@ -1859,10 +1868,8 @@ class RouteGenerator { if (args is (String, String)) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => SolTokenReceiveView( - walletId: args.$1, - tokenMint: args.$2, - ), + builder: (_) => + SolTokenReceiveView(walletId: args.$1, tokenMint: args.$2), settings: RouteSettings(name: settings.name), ); } @@ -2617,7 +2624,8 @@ class RouteGenerator { ), settings: RouteSettings(name: settings.name), ); - } else if (args is ({String walletId, String tokenMint, bool popPrevious})) { + } else if (args + is ({String walletId, String tokenMint, bool popPrevious})) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, builder: (_) => SolTokenView( @@ -2636,10 +2644,8 @@ class RouteGenerator { if (args is (String, String)) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => SparkViewKeyView( - walletId: args.$1, - sparkViewKeyHex: args.$2, - ), + builder: (_) => + SparkViewKeyView(walletId: args.$1, sparkViewKeyHex: args.$2), settings: RouteSettings(name: settings.name), ); } diff --git a/lib/wallets/models/tx_data.dart b/lib/wallets/models/tx_data.dart index 6db43109a0..0ac1d29176 100644 --- a/lib/wallets/models/tx_data.dart +++ b/lib/wallets/models/tx_data.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:tezart/tezart.dart' as tezart; import 'package:web3dart/web3dart.dart' as web3dart; @@ -7,6 +9,7 @@ import '../../models/isar/models/isar_models.dart'; import '../../models/paynym/paynym_account_lite.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/enums/fee_rate_type_enum.dart'; +import '../../utilities/extensions/impl/uint8_list.dart'; import '../../widgets/eth_fee_form.dart'; import '../../wl_gen/interfaces/cs_monero_interface.dart' show CsPendingTransaction; @@ -94,6 +97,8 @@ class TxData { int validBlocks, })? sparkNameInfo; + final Uint8List? vExtraData; + final int? overrideVersion; // xelis specific final String? otherData; @@ -147,6 +152,8 @@ class TxData { this.ignoreCachedBalanceChecks = false, this.opNameState, this.sparkNameInfo, + this.vExtraData, + this.overrideVersion, this.type = TxType.regular, this.salviumStakeTx = false, }); @@ -299,6 +306,8 @@ class TxData { int validBlocks, })? sparkNameInfo, + Uint8List? vExtraData, + int? overrideVersion, TxType? type, }) { return TxData( @@ -342,6 +351,8 @@ class TxData { ignoreCachedBalanceChecks ?? this.ignoreCachedBalanceChecks, opNameState: opNameState ?? this.opNameState, sparkNameInfo: sparkNameInfo ?? this.sparkNameInfo, + vExtraData: vExtraData ?? this.vExtraData, + overrideVersion: overrideVersion ?? this.overrideVersion, type: type ?? this.type, ); } @@ -381,6 +392,8 @@ class TxData { 'ignoreCachedBalanceChecks: $ignoreCachedBalanceChecks, ' 'opNameState: $opNameState, ' 'sparkNameInfo: $sparkNameInfo, ' + 'vExtraData: ${vExtraData?.toHex}, ' + 'overrideVersion: $overrideVersion, ' 'type: $type, ' '}'; } diff --git a/lib/wallets/wallet/impl/firo_wallet.dart b/lib/wallets/wallet/impl/firo_wallet.dart index 701f4a41c8..49319c4585 100644 --- a/lib/wallets/wallet/impl/firo_wallet.dart +++ b/lib/wallets/wallet/impl/firo_wallet.dart @@ -1,10 +1,17 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; +import 'package:coinlib_flutter/coinlib_flutter.dart' + show base58Decode, P2SH, Base58Address, P2PKH; +import 'package:crypto/crypto.dart' as Cryptography; import 'package:decimal/decimal.dart'; import 'package:isar_community/isar.dart'; import '../../../db/sqlite/firo_cache.dart'; +import '../../../models/buy/response_objects/crypto.dart'; +import '../../../models/input.dart'; import '../../../models/isar/models/blockchain_data/v2/input_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/output_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; @@ -16,6 +23,7 @@ import '../../../utilities/logger.dart'; import '../../../utilities/util.dart'; import '../../crypto_currency/crypto_currency.dart'; import '../../crypto_currency/interfaces/electrumx_currency_interface.dart'; +import '../../crypto_currency/intermediate/bip39_hd_currency.dart'; import '../../isar/models/spark_coin.dart'; import '../../isar/models/wallet_info.dart'; import '../../models/tx_data.dart'; @@ -25,7 +33,45 @@ import '../wallet_mixin_interfaces/electrumx_interface.dart'; import '../wallet_mixin_interfaces/extended_keys_interface.dart'; import '../wallet_mixin_interfaces/spark_interface.dart'; -const sparkStartBlock = 819300; // (approx 18 Jan 2024) +class MasternodeInfo { + final String proTxHash; + final String collateralHash; + final int collateralIndex; + final String collateralAddress; + final int operatorReward; + final String serviceAddr; + final int servicePort; + final int registeredHeight; + final int lastPaidHeight; + final int posePenalty; + final int poseRevivedHeight; + final int poseBanHeight; + final int revocationReason; + final String ownerAddress; + final String votingAddress; + final String payoutAddress; + final String pubKeyOperator; + + MasternodeInfo({ + required this.proTxHash, + required this.collateralHash, + required this.collateralIndex, + required this.collateralAddress, + required this.operatorReward, + required this.serviceAddr, + required this.servicePort, + required this.registeredHeight, + required this.lastPaidHeight, + required this.posePenalty, + required this.poseRevivedHeight, + required this.poseBanHeight, + required this.revocationReason, + required this.ownerAddress, + required this.votingAddress, + required this.payoutAddress, + required this.pubKeyOperator, + }); +} class FiroWallet extends Bip39HDWallet with @@ -868,4 +914,309 @@ class FiroWallet extends Bip39HDWallet int estimateTxFee({required int vSize, required BigInt feeRatePerKB}) { return (feeRatePerKB * BigInt.from(vSize) ~/ BigInt.from(1000)).toInt(); } + + Future registerMasternode( + String ip, + int port, + String operatorPubKey, + String votingAddress, + int operatorReward, + String payoutAddress, + ) async { + if (info.cachedBalance.spendable < + Amount.fromDecimal( + Decimal.fromInt(1000), + fractionDigits: cryptoCurrency.fractionDigits, + )) { + throw Exception( + 'Not enough funds to register a masternode. You must have at least 1000 FIRO in your public balance.', + ); + } + + Address? collateralAddress = await getCurrentReceivingAddress(); + if (collateralAddress == null) { + await generateNewReceivingAddress(); + collateralAddress = await getCurrentReceivingAddress(); + } + await generateNewReceivingAddress(); + + Address? ownerAddress = await getCurrentReceivingAddress(); + if (ownerAddress == null) { + await generateNewReceivingAddress(); + ownerAddress = await getCurrentReceivingAddress(); + } + await generateNewReceivingAddress(); + + // Create the registration transaction. + final registrationTx = BytesBuilder(); + + // nVersion (16 bit) + registrationTx.add( + (ByteData(2)..setInt16(0, 1, Endian.little)).buffer.asUint8List(), + ); + + // nType (16 bit) (this is separate from the tx nType) + registrationTx.add( + (ByteData(2)..setInt16(0, 0, Endian.little)).buffer.asUint8List(), + ); + + // nMode (16 bit) + registrationTx.add( + (ByteData(2)..setInt16(0, 0, Endian.little)).buffer.asUint8List(), + ); + + // collateralOutpoint.hash (256 bit) + // This is null, referring to our own transaction. + registrationTx.add(ByteData(32).buffer.asUint8List()); + + // collateralOutpoint.index (2 bytes) + // This is going to be 0. (The only other output will be change at position 1.) + registrationTx.add( + (ByteData(4)..setInt16(0, 0, Endian.little)).buffer.asUint8List(), + ); + + // addr.ip (4 bytes) + final ipParts = ip + .split('.') + .map((e) => int.parse(e)) + .toList() + .reversed + .toList(); // network byte order + if (ipParts.length != 4) { + throw Exception("Invalid IP address: $ip"); + } + for (final part in ipParts) { + if (part < 0 || part > 255) { + throw Exception("Invalid IP part: $part"); + } + } + // This is serialized as an IPv6 address (which it cannot be), so there will be 12 bytes of padding. + registrationTx.add(ByteData(10).buffer.asUint8List()); + registrationTx.add([0xff, 0xff]); + registrationTx.add(ipParts); + + // addr.port (2 bytes) + if (port < 0 || port > 65535) { + throw Exception("Invalid port: $port"); + } + registrationTx.add( + (ByteData(2)..setInt16(0, port, Endian.little)).buffer.asUint8List(), + ); + + // keyIDOwner (20 bytes) + assert(ownerAddress!.value != collateralAddress!.value); + if (!cryptoCurrency.validateAddress(ownerAddress!.value)) { + throw Exception("Invalid owner address: ${ownerAddress.value}"); + } + final ownerAddressBytes = base58Decode(ownerAddress.value); + assert(ownerAddressBytes.length == 21); // should be infallible + registrationTx.add(ownerAddressBytes.sublist(1)); // remove version byte + + // pubKeyOperator (48 bytes) + final operatorPubKeyBytes = operatorPubKey.toUint8ListFromHex; + if (operatorPubKeyBytes.length != 48) { + // These actually have a required format, but we're not going to check it. The transaction will fail if it's not + // valid. + throw Exception("Invalid operator public key: $operatorPubKey"); + } + registrationTx.add(operatorPubKeyBytes); + + // keyIDVoting (40 bytes) + if (votingAddress == payoutAddress) { + throw Exception("Voting address and payout address cannot be the same."); + } else if (votingAddress == collateralAddress!.value) { + throw Exception( + "Voting address cannot be the same as the collateral address.", + ); + } else if (votingAddress.isNotEmpty) { + if (!cryptoCurrency.validateAddress(votingAddress)) { + throw Exception("Invalid voting address: $votingAddress"); + } + + final votingAddressBytes = base58Decode(votingAddress); + assert(votingAddressBytes.length == 21); // should be infallible + registrationTx.add(votingAddressBytes.sublist(1)); // remove version byte + } else { + registrationTx.add(ownerAddressBytes.sublist(1)); // remove version byte + } + + // nOperatorReward (16 bit); the operator gets nOperatorReward/10,000 of the reward. + if (operatorReward < 0 || operatorReward > 10000) { + throw Exception("Invalid operator reward: $operatorReward"); + } + registrationTx.add( + (ByteData( + 2, + )..setInt16(0, operatorReward, Endian.little)).buffer.asUint8List(), + ); + + // scriptPayout (variable) + if (!cryptoCurrency.validateAddress(payoutAddress)) { + throw Exception("Invalid payout address: $payoutAddress"); + } + final payoutAddressScript = P2PKH.fromHash( + base58Decode(payoutAddress).sublist(1), + ); + final payoutAddressScriptLength = + payoutAddressScript.script.compiled.length; + assert(payoutAddressScriptLength < 253); + registrationTx.addByte(payoutAddressScriptLength); + registrationTx.add(payoutAddressScript.script.compiled); + + final partialTxData = TxData( + // nVersion: 3, nType: 1 (TRANSACTION_PROVIDER_REGISTER) + overrideVersion: 3 + (1 << 16), + // coinSelection fee calculation uses a heuristic that doesn't know about vExtraData, so we'll just use a really + // big fee to make sure the transaction confirms. + feeRateAmount: cryptoCurrency.defaultFeeRate * BigInt.from(10), + recipients: [ + TxRecipient( + address: collateralAddress!.value, + addressType: AddressType.p2pkh, + amount: Amount.fromDecimal( + Decimal.fromInt(1000), + fractionDigits: cryptoCurrency.fractionDigits, + ), + isChange: false, + ), + ], + ); + + final partialTx = await coinSelection( + txData: partialTxData, + coinControl: false, + isSendAll: false, + isSendAllCoinControlUtxos: false, + ); + + // Calculate inputsHash (32 bytes). + final inputsHashInput = BytesBuilder(); + for (final input in partialTx.usedUTXOs!) { + final standardInput = input as StandardInput; + // we reverse the txid bytes because fuck it, why not. + final reversedTxidBytes = standardInput + .utxo + .txid + .toUint8ListFromHex + .reversed + .toList(); + inputsHashInput.add(reversedTxidBytes); + inputsHashInput.add( + (ByteData(4)..setInt32(0, standardInput.utxo.vout, Endian.little)) + .buffer + .asUint8List(), + ); + } + final inputsHash = Cryptography.sha256 + .convert(inputsHashInput.toBytes()) + .bytes; + final inputsHashHash = Cryptography.sha256.convert(inputsHash).bytes; + registrationTx.add(inputsHashHash); + + // vchSig is a variable length field that we need iff the collateral is NOT in the same transaction, but for us it is. + registrationTx.addByte(0); + + final finalTxData = partialTx.copyWith( + vExtraData: registrationTx.toBytes(), + ); + final finalTx = await buildTransaction( + txData: finalTxData, + inputsWithKeys: partialTx.usedUTXOs!, + ); + + final finalTransactionHex = finalTx.raw!; + assert(finalTransactionHex.contains(registrationTx.toBytes().toHex)); + + final broadcastedTxHash = await electrumXClient.broadcastTransaction( + rawTx: finalTransactionHex, + ); + if (broadcastedTxHash.toUint8ListFromHex.length != 32) { + throw Exception("Failed to broadcast transaction: $broadcastedTxHash"); + } + Logging.instance.i( + "Successfully broadcasted masternode registration transaction: $finalTransactionHex (txid $broadcastedTxHash)", + ); + + await updateSentCachedTxData(txData: finalTx); + + return broadcastedTxHash; + } + + Future> getMyMasternodes() async { + final proTxHashes = await getMyMasternodeProTxHashes(); + + return (await Future.wait( + proTxHashes.map( + (e) => Future(() async { + try { + final info = await electrumXClient.request( + command: 'protx.info', + args: [e], + ); + return MasternodeInfo( + proTxHash: info["proTxHash"] as String, + collateralHash: info["collateralHash"] as String, + collateralIndex: info["collateralIndex"] as int, + collateralAddress: info["collateralAddress"] as String, + operatorReward: info["operatorReward"] as int, + serviceAddr: (info["state"]["service"] as String).substring( + 0, + (info["state"]["service"] as String).lastIndexOf(":"), + ), + servicePort: int.parse( + (info["state"]["service"] as String).substring( + (info["state"]["service"] as String).lastIndexOf(":") + 1, + ), + ), + registeredHeight: info["state"]["registeredHeight"] as int, + lastPaidHeight: info["state"]["lastPaidHeight"] as int, + posePenalty: info["state"]["PoSePenalty"] as int, + poseRevivedHeight: info["state"]["PoSeRevivedHeight"] as int, + poseBanHeight: info["state"]["PoSeBanHeight"] as int, + revocationReason: info["state"]["revocationReason"] as int, + ownerAddress: info["state"]["ownerAddress"] as String, + votingAddress: info["state"]["votingAddress"] as String, + payoutAddress: info["state"]["payoutAddress"] as String, + pubKeyOperator: info["state"]["pubKeyOperator"] as String, + ); + } catch (err) { + // getMyMasternodeProTxHashes() may give non-masternode txids, so only log as info. + Logging.instance.i("Error getting masternode info for $e: $err"); + return null; + } + }), + ), + )).where((e) => e != null).map((e) => e!).toList(); + } + + Future> getMyMasternodeProTxHashes() async { + // - This registers only masternodes which have collateral in the same transaction. + // - If this seed is shared with firod or such and a masternode is created there, it will probably not appear here + // because that doesn't put collateral in the protx tx. + // - An exactly 1000 FIRO vout will show up here even if it's not a masternode collateral. This will just log an + // info in getMyMasternodes. + // - If this wallet created a masternode not owned by this wallet it will erroneously be emitted here and actually + // shown to the user as our own masternode, but this is contrived and nothing actually produces transactions like + // that. + + // utxos are UNSPENT txos, so broken masternodes will not show up here by design. + final utxos = await mainDB.getUTXOs(walletId).sortByBlockHeight().findAll(); + + final List r = []; + + for (final utxo in utxos) { + if (utxo.value != cryptoCurrency.satsPerCoin.toInt() * 1000) { + continue; + } + + // A duplicate could occur if a protx transaction has a non-collateral 1000 FIRO vout. + if (r.contains(utxo.txid)) { + continue; + } + + r.add(utxo.txid); + } + + return r; + } } diff --git a/lib/wallets/wallet/wallet.dart b/lib/wallets/wallet/wallet.dart index 0fa72d6822..85002c110c 100644 --- a/lib/wallets/wallet/wallet.dart +++ b/lib/wallets/wallet/wallet.dart @@ -53,11 +53,11 @@ import 'impl/wownero_wallet.dart'; import 'impl/xelis_wallet.dart'; import 'intermediate/cryptonote_wallet.dart'; import 'wallet_mixin_interfaces/electrumx_interface.dart'; +import 'wallet_mixin_interfaces/spark_interface.dart'; import 'wallet_mixin_interfaces/mnemonic_interface.dart'; import 'wallet_mixin_interfaces/multi_address_interface.dart'; import 'wallet_mixin_interfaces/paynym_interface.dart'; import 'wallet_mixin_interfaces/private_key_interface.dart'; -import 'wallet_mixin_interfaces/spark_interface.dart'; import 'wallet_mixin_interfaces/view_only_option_interface.dart'; abstract class Wallet { @@ -244,11 +244,10 @@ abstract class Wallet { required NodeService nodeService, required Prefs prefs, }) async { - final walletInfo = - await mainDB.isar.walletInfo - .where() - .walletIdEqualTo(walletId) - .findFirst(); + final walletInfo = await mainDB.isar.walletInfo + .where() + .walletIdEqualTo(walletId) + .findFirst(); Logging.instance.i( "Wallet.load loading" @@ -438,10 +437,9 @@ abstract class Wallet { final bool hasNetwork = await pingCheck(); if (_isConnected != hasNetwork) { - final NodeConnectionStatus status = - hasNetwork - ? NodeConnectionStatus.connected - : NodeConnectionStatus.disconnected; + final NodeConnectionStatus status = hasNetwork + ? NodeConnectionStatus.connected + : NodeConnectionStatus.disconnected; if (!doNotFireRefreshEvents) { GlobalEventBus.instance.fire( NodeConnectionStatusChangedEvent(status, walletId, cryptoCurrency), @@ -756,11 +754,10 @@ abstract class Wallet { // Check if there's another wallet of this coin on the sync list. final List walletIds = []; for (final id in prefs.walletIdsSyncOnStartup) { - final wallet = - mainDB.isar.walletInfo - .where() - .walletIdEqualTo(id) - .findFirstSync()!; + final wallet = mainDB.isar.walletInfo + .where() + .walletIdEqualTo(id) + .findFirstSync()!; if (wallet.coin == cryptoCurrency) { walletIds.add(id); diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index 98fd505ffd..e963566b61 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -659,7 +659,10 @@ mixin ElectrumXInterface final List prevOuts = []; coinlib.Transaction clTx = coinlib.Transaction( - version: txData.type.isMweb() ? 2 : cryptoCurrency.transactionVersion, + vExtraData: txData.vExtraData, + version: + txData.overrideVersion ?? + (txData.type.isMweb() ? 2 : cryptoCurrency.transactionVersion), inputs: [], outputs: [], ); diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart index d759107317..1e097b55e1 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart @@ -218,7 +218,10 @@ mixin SparkInterface isTestNet: args.isTestNet_, ); } catch (e) { - Logging.instance.e("Failed to identify coin", error: e); + Logging.instance.e( + "Error identifying coin in tx $txHash (this is not expected)", + error: e, + ); continue; } @@ -269,7 +272,12 @@ mixin SparkInterface Future hashTag(String tag) async { try { - return await computeWithLibSparkLogging(_hashTag, tag); + return await computeWithLibSparkLogging((t) { + final components = t.split(","); + final x = components[0].substring(1); + final y = components[1].substring(0, components[1].length - 1); + return libSpark.hashTag(x, y); + }, tag); } catch (_) { throw ArgumentError("Invalid tag string format", "tag"); } @@ -1380,6 +1388,15 @@ mixin SparkInterface } } + Future recoverViewOnlyWallet() async { + await recoverSparkWallet(latestSparkCoinId: 0); + } + + Future<({String address, int validUntil, String additionalInfo})> + getSparkNameData({required String sparkName}) async { + return await electrumXClient.getSparkNameData(sparkName: sparkName); + } + Future refreshSparkNames() async { try { Logging.instance.i("Refreshing spark names for $walletId ${info.name}"); @@ -1439,9 +1456,7 @@ mixin SparkInterface data = []; for (final name in names) { - final info = await electrumXClient.getSparkNameData( - sparkName: name.name, - ); + final info = await getSparkNameData(sparkName: name.name); data.add(( name: name.name, From 9f92bcbef30f20ecb3007e1abcefc4a9e3120253 Mon Sep 17 00:00:00 2001 From: julian Date: Mon, 12 Jan 2026 13:16:50 -0600 Subject: [PATCH 08/17] satisfy linter and some clean up --- lib/wallets/wallet/impl/firo_wallet.dart | 77 +++++++++++-------- .../spark_interface.dart | 65 +++++++++------- pubspec.lock | 8 +- 3 files changed, 85 insertions(+), 65 deletions(-) diff --git a/lib/wallets/wallet/impl/firo_wallet.dart b/lib/wallets/wallet/impl/firo_wallet.dart index 49319c4585..7954a7617e 100644 --- a/lib/wallets/wallet/impl/firo_wallet.dart +++ b/lib/wallets/wallet/impl/firo_wallet.dart @@ -1,16 +1,13 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:math'; import 'dart:typed_data'; -import 'package:coinlib_flutter/coinlib_flutter.dart' - show base58Decode, P2SH, Base58Address, P2PKH; -import 'package:crypto/crypto.dart' as Cryptography; +import 'package:coinlib_flutter/coinlib_flutter.dart' show base58Decode, P2PKH; +import 'package:crypto/crypto.dart' as crypto; import 'package:decimal/decimal.dart'; import 'package:isar_community/isar.dart'; import '../../../db/sqlite/firo_cache.dart'; -import '../../../models/buy/response_objects/crypto.dart'; import '../../../models/input.dart'; import '../../../models/isar/models/blockchain_data/v2/input_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/output_v2.dart'; @@ -23,7 +20,6 @@ import '../../../utilities/logger.dart'; import '../../../utilities/util.dart'; import '../../crypto_currency/crypto_currency.dart'; import '../../crypto_currency/interfaces/electrumx_currency_interface.dart'; -import '../../crypto_currency/intermediate/bip39_hd_currency.dart'; import '../../isar/models/spark_coin.dart'; import '../../isar/models/wallet_info.dart'; import '../../models/tx_data.dart'; @@ -73,6 +69,8 @@ class MasternodeInfo { }); } +final _masterNodeValue = Decimal.fromInt(1000); // full value (not sats) + class FiroWallet extends Bip39HDWallet with ElectrumXInterface, @@ -726,9 +724,7 @@ class FiroWallet extends Bip39HDWallet // Fall back to locked in case network call fails blocked = Amount.fromDecimal( - Decimal.fromInt( - 1000, // 1000 firo output is a possible master node - ), + _masterNodeValue, fractionDigits: cryptoCurrency.fractionDigits, ).raw == BigInt.from(jsonUTXO["value"] as int); @@ -925,7 +921,7 @@ class FiroWallet extends Bip39HDWallet ) async { if (info.cachedBalance.spendable < Amount.fromDecimal( - Decimal.fromInt(1000), + _masterNodeValue, fractionDigits: cryptoCurrency.fractionDigits, )) { throw Exception( @@ -970,7 +966,8 @@ class FiroWallet extends Bip39HDWallet registrationTx.add(ByteData(32).buffer.asUint8List()); // collateralOutpoint.index (2 bytes) - // This is going to be 0. (The only other output will be change at position 1.) + // This is going to be 0. + // (The only other output will be change at position 1.) registrationTx.add( (ByteData(4)..setInt16(0, 0, Endian.little)).buffer.asUint8List(), ); @@ -990,7 +987,8 @@ class FiroWallet extends Bip39HDWallet throw Exception("Invalid IP part: $part"); } } - // This is serialized as an IPv6 address (which it cannot be), so there will be 12 bytes of padding. + // This is serialized as an IPv6 address (which it cannot be), + // so there will be 12 bytes of padding. registrationTx.add(ByteData(10).buffer.asUint8List()); registrationTx.add([0xff, 0xff]); registrationTx.add(ipParts); @@ -1015,7 +1013,8 @@ class FiroWallet extends Bip39HDWallet // pubKeyOperator (48 bytes) final operatorPubKeyBytes = operatorPubKey.toUint8ListFromHex; if (operatorPubKeyBytes.length != 48) { - // These actually have a required format, but we're not going to check it. The transaction will fail if it's not + // These actually have a required format, but we're not going to check it. + // The transaction will fail if it's not // valid. throw Exception("Invalid operator public key: $operatorPubKey"); } @@ -1066,15 +1065,16 @@ class FiroWallet extends Bip39HDWallet final partialTxData = TxData( // nVersion: 3, nType: 1 (TRANSACTION_PROVIDER_REGISTER) overrideVersion: 3 + (1 << 16), - // coinSelection fee calculation uses a heuristic that doesn't know about vExtraData, so we'll just use a really - // big fee to make sure the transaction confirms. + // coinSelection fee calculation uses a heuristic that doesn't know about + // vExtraData, so we'll just use a really big fee to make sure the + // transaction confirms. feeRateAmount: cryptoCurrency.defaultFeeRate * BigInt.from(10), recipients: [ TxRecipient( - address: collateralAddress!.value, + address: collateralAddress.value, addressType: AddressType.p2pkh, amount: Amount.fromDecimal( - Decimal.fromInt(1000), + _masterNodeValue, fractionDigits: cryptoCurrency.fractionDigits, ), isChange: false, @@ -1107,13 +1107,12 @@ class FiroWallet extends Bip39HDWallet .asUint8List(), ); } - final inputsHash = Cryptography.sha256 - .convert(inputsHashInput.toBytes()) - .bytes; - final inputsHashHash = Cryptography.sha256.convert(inputsHash).bytes; + final inputsHash = crypto.sha256.convert(inputsHashInput.toBytes()).bytes; + final inputsHashHash = crypto.sha256.convert(inputsHash).bytes; registrationTx.add(inputsHashHash); - // vchSig is a variable length field that we need iff the collateral is NOT in the same transaction, but for us it is. + // vchSig is a variable length field that we need iff the collateral is + // NOT in the same transaction, but for us it is. registrationTx.addByte(0); final finalTxData = partialTx.copyWith( @@ -1134,7 +1133,8 @@ class FiroWallet extends Bip39HDWallet throw Exception("Failed to broadcast transaction: $broadcastedTxHash"); } Logging.instance.i( - "Successfully broadcasted masternode registration transaction: $finalTransactionHex (txid $broadcastedTxHash)", + "Successfully broadcasted masternode registration transaction: " + "$finalTransactionHex (txid $broadcastedTxHash)", ); await updateSentCachedTxData(txData: finalTx); @@ -1180,7 +1180,8 @@ class FiroWallet extends Bip39HDWallet pubKeyOperator: info["state"]["pubKeyOperator"] as String, ); } catch (err) { - // getMyMasternodeProTxHashes() may give non-masternode txids, so only log as info. + // getMyMasternodeProTxHashes() may give non-masternode txids, so + // only log as info. Logging.instance.i("Error getting masternode info for $e: $err"); return null; } @@ -1190,26 +1191,38 @@ class FiroWallet extends Bip39HDWallet } Future> getMyMasternodeProTxHashes() async { - // - This registers only masternodes which have collateral in the same transaction. - // - If this seed is shared with firod or such and a masternode is created there, it will probably not appear here + // - This registers only masternodes which have collateral in the same + // transaction. + // - If this seed is shared with firod or such and a masternode is created + // there, it will probably not appear here // because that doesn't put collateral in the protx tx. - // - An exactly 1000 FIRO vout will show up here even if it's not a masternode collateral. This will just log an + // - An exactly 1000 FIRO vout will show up here even if it's not a + // masternode collateral. This will just log an // info in getMyMasternodes. - // - If this wallet created a masternode not owned by this wallet it will erroneously be emitted here and actually - // shown to the user as our own masternode, but this is contrived and nothing actually produces transactions like + // - If this wallet created a masternode not owned by this wallet it will + // erroneously be emitted here and actually + // shown to the user as our own masternode, but this is contrived and + // nothing actually produces transactions like // that. - // utxos are UNSPENT txos, so broken masternodes will not show up here by design. + // utxos are UNSPENT txos, so broken masternodes will not show up here by + // design. final utxos = await mainDB.getUTXOs(walletId).sortByBlockHeight().findAll(); final List r = []; + final rawMasterNodeAmount = Amount.fromDecimal( + _masterNodeValue, + fractionDigits: cryptoCurrency.fractionDigits, + ).raw.toInt(); + for (final utxo in utxos) { - if (utxo.value != cryptoCurrency.satsPerCoin.toInt() * 1000) { + if (utxo.value != rawMasterNodeAmount) { continue; } - // A duplicate could occur if a protx transaction has a non-collateral 1000 FIRO vout. + // A duplicate could occur if a protx transaction has a non-collateral + // 1000 FIRO vout. if (r.contains(utxo.txid)) { continue; } diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart index 1e097b55e1..37e8506788 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart @@ -121,10 +121,12 @@ mixin SparkInterface return cryptoCurrency.network.isTestNet; } - // This is the BIP44 derivation path for the spark private key; spark public keys will have their own derivation path. + // This is the BIP44 derivation path for the spark private key; spark public + // keys will have their own derivation path. String get sparkDerivationPath { - // NOTE: This is reusing the sparkIndex for backwards compatibility, but these are actually distinct things which do - // not have to be the same. sparkIndex has nothing at all to do with the derivation path. + // NOTE: This is reusing the sparkIndex for backwards compatibility, but + // these are actually distinct things which do not have to be the same. + // sparkIndex has nothing at all to do with the derivation path. if (isTestNet) { return "${libSpark.sparkBaseDerivationPathTestnet}$kDefaultSparkIndex"; } else { @@ -132,8 +134,8 @@ mixin SparkInterface } } - // This is the index for the spark key, which is NOT the diversifier or the BIP44 derivation path (which generates the - // private key data). + // This is the index for the spark key, which is NOT the diversifier or the + // BIP44 derivation path (which generates the private key data). int get sparkIndex => kDefaultSparkIndex; Future
_generateSparkAddress(int diversifier) async { @@ -272,12 +274,7 @@ mixin SparkInterface Future hashTag(String tag) async { try { - return await computeWithLibSparkLogging((t) { - final components = t.split(","); - final x = components[0].substring(1); - final y = components[1].substring(0, components[1].length - 1); - return libSpark.hashTag(x, y); - }, tag); + return await computeWithLibSparkLogging(_hashTag, tag); } catch (_) { throw ArgumentError("Invalid tag string format", "tag"); } @@ -901,7 +898,8 @@ mixin SparkInterface ); } catch (_) { throw Exception( - "Unexpectedly did not find used spark coin. This should never happen.", + "Unexpectedly did not find used spark coin. " + "This should never happen.", ); } } @@ -952,7 +950,8 @@ mixin SparkInterface // Update used spark coins as used in database. They should already have // been marked as isUsed. - // TODO: [prio=med] Could (probably should) throw an exception here if txData.usedSparkCoins is null or empty + // TODO: [prio=med] Could (probably should) throw an exception here + // if txData.usedSparkCoins is null or empty if (txData.usedSparkCoins != null && txData.usedSparkCoins!.isNotEmpty) { await mainDB.isar.writeTxn(() async { await mainDB.isar.sparkCoins.putAll(txData.usedSparkCoins!); @@ -1041,7 +1040,8 @@ mixin SparkInterface return current + increment; } - // Linearly make calls so there is less chance of timing out or otherwise breaking + // Linearly make calls so there is less chance of timing out or otherwise + // breaking Future refreshSparkData( (double startingPercent, double endingPercent)? refreshProgressRange, ) async { @@ -1388,10 +1388,6 @@ mixin SparkInterface } } - Future recoverViewOnlyWallet() async { - await recoverSparkWallet(latestSparkCoinId: 0); - } - Future<({String address, int validUntil, String additionalInfo})> getSparkNameData({required String sparkName}) async { return await electrumXClient.getSparkNameData(sparkName: sparkName); @@ -1423,8 +1419,8 @@ mixin SparkInterface .toSet(); // some look ahead - // TODO revisit this and clean up (track pre gen'd addresses instead of generating every time) - // arbitrary number of addresses + // TODO revisit this and clean up (track pre gen'd addresses instead of + // generating every time) arbitrary number of addresses const lookAheadCount = 100; int diversifier = _currentSparkAddress.derivationIndex; @@ -1769,7 +1765,7 @@ mixin SparkInterface sd.utxo.txid, sd.utxo.vout, 0xffffffff - - 1, // minus 1 is important. 0xffffffff on its own will burn funds + 1, // - 1 is important. 0xffffffff on its own will burn funds data!.output!, ); } @@ -1785,7 +1781,8 @@ mixin SparkInterface ), witnessValue: setCoins[i].utxo.value, - // maybe not needed here as this was originally copied from btc? We'll find out... + // maybe not needed here as this was originally copied from btc? + // We'll find out... // redeemScript: setCoins[i].redeemScript, ); } @@ -1992,7 +1989,8 @@ mixin SparkInterface ), witnessValue: vin[i].utxo.value, - // maybe not needed here as this was originally copied from btc? We'll find out... + // maybe not needed here as this was originally copied from btc? + // We'll find out... // redeemScript: setCoins[i].redeemScript, ); } @@ -2014,9 +2012,10 @@ mixin SparkInterface .where((e) => e.$1 is Uint8List) // ignore change .map( (e) => ( - address: outputs - .first - .address, // for display purposes on confirm tx screen. See todos above + // for display purposes on confirm tx screen. + // See todos above + address: outputs.first.address, + memo: "", amount: Amount( rawValue: BigInt.from(e.$2), @@ -2316,7 +2315,9 @@ mixin SparkInterface if (additionalInfo.toUint8ListFromUtf8.length > libSpark.maxAdditionalInfoLengthBytes) { throw Exception( - "Additional info exceeds ${libSpark.maxAdditionalInfoLengthBytes} bytes.", + "Additional info exceeds " + "${libSpark.maxAdditionalInfoLengthBytes}" + " bytes.", ); } @@ -2347,7 +2348,9 @@ mixin SparkInterface default: throw Exception( - "Invalid network '${cryptoCurrency.network}' for spark name registration.", + "Invalid network " + "'${cryptoCurrency.network}'" + " for spark name registration.", ); } @@ -2490,7 +2493,11 @@ class MutableSparkRecipient { @override String toString() { - return 'MutableSparkRecipient{ address: $address, value: $value, memo: $memo }'; + return 'MutableSparkRecipient{ ' + 'address: $address, ' + 'value: $value,' + ' memo: $memo' + ' }'; } } diff --git a/pubspec.lock b/pubspec.lock index f0f635a407..0aedf78678 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -341,8 +341,8 @@ packages: dependency: "direct overridden" description: path: coinlib - ref: f90600053a4f149a6153f30057ac7f75c21ab962 - resolved-ref: f90600053a4f149a6153f30057ac7f75c21ab962 + ref: "5c59c7e7d120d9c981f23008fa03421d39fe8631" + resolved-ref: "5c59c7e7d120d9c981f23008fa03421d39fe8631" url: "https://www.github.com/julian-CStack/coinlib" source: git version: "4.1.0" @@ -350,8 +350,8 @@ packages: dependency: "direct main" description: path: coinlib_flutter - ref: f90600053a4f149a6153f30057ac7f75c21ab962 - resolved-ref: f90600053a4f149a6153f30057ac7f75c21ab962 + ref: "5c59c7e7d120d9c981f23008fa03421d39fe8631" + resolved-ref: "5c59c7e7d120d9c981f23008fa03421d39fe8631" url: "https://www.github.com/julian-CStack/coinlib" source: git version: "4.0.0" From 08915979b6ceb33f4e1353b6e8e50fefbb219890 Mon Sep 17 00:00:00 2001 From: julian Date: Mon, 12 Jan 2026 20:16:44 -0600 Subject: [PATCH 09/17] add optional content padding to primary button --- lib/widgets/desktop/primary_button.dart | 83 ++++++++++++------------- 1 file changed, 41 insertions(+), 42 deletions(-) diff --git a/lib/widgets/desktop/primary_button.dart b/lib/widgets/desktop/primary_button.dart index c2f1269984..3e6efd32f1 100644 --- a/lib/widgets/desktop/primary_button.dart +++ b/lib/widgets/desktop/primary_button.dart @@ -28,6 +28,7 @@ class PrimaryButton extends StatelessWidget { this.enabled = true, this.buttonHeight, this.iconSpacing = 10, + this.horizontalContentPadding, }); final double? width; @@ -38,6 +39,7 @@ class PrimaryButton extends StatelessWidget { final Widget? icon; final ButtonHeight? buttonHeight; final double? iconSpacing; + final double? horizontalContentPadding; TextStyle getStyle(bool isDesktop, BuildContext context) { if (isDesktop) { @@ -54,9 +56,9 @@ class PrimaryButton extends StatelessWidget { return STextStyles.desktopTextExtraExtraSmall(context).copyWith( color: enabled ? Theme.of(context).extension()!.buttonTextPrimary - : Theme.of(context) - .extension()! - .buttonTextPrimaryDisabled, + : Theme.of( + context, + ).extension()!.buttonTextPrimaryDisabled, ); case ButtonHeight.m: @@ -64,9 +66,9 @@ class PrimaryButton extends StatelessWidget { return STextStyles.desktopTextExtraSmall(context).copyWith( color: enabled ? Theme.of(context).extension()!.buttonTextPrimary - : Theme.of(context) - .extension()! - .buttonTextPrimaryDisabled, + : Theme.of( + context, + ).extension()!.buttonTextPrimaryDisabled, ); case ButtonHeight.xl: @@ -81,17 +83,17 @@ class PrimaryButton extends StatelessWidget { fontSize: 10, color: enabled ? Theme.of(context).extension()!.buttonTextPrimary - : Theme.of(context) - .extension()! - .buttonTextPrimaryDisabled, + : Theme.of( + context, + ).extension()!.buttonTextPrimaryDisabled, ); } return STextStyles.button(context).copyWith( color: enabled ? Theme.of(context).extension()!.buttonTextPrimary - : Theme.of(context) - .extension()! - .buttonTextPrimaryDisabled, + : Theme.of( + context, + ).extension()!.buttonTextPrimaryDisabled, ); } } @@ -145,37 +147,34 @@ class PrimaryButton extends StatelessWidget { textButton: TextButton( onPressed: enabled ? onPressed : null, style: enabled - ? Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context) + ? Theme.of( + context, + ).extension()!.getPrimaryEnabledButtonStyle(context) : Theme.of(context) - .extension()! - .getPrimaryDisabledButtonStyle(context), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (icon != null) icon!, - if (icon != null && label != null) - SizedBox( - width: iconSpacing, - ), - if (label != null) - Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - label!, - style: getStyle(isDesktop, context), - ), - if (buttonHeight != null && buttonHeight == ButtonHeight.s) - const SizedBox( - height: 2, - ), - ], - ), - ], + .extension()! + .getPrimaryDisabledButtonStyle(context), + child: Padding( + padding: horizontalContentPadding == null + ? .zero + : .symmetric(horizontal: horizontalContentPadding!), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) icon!, + if (icon != null && label != null) SizedBox(width: iconSpacing), + if (label != null) + Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(label!, style: getStyle(isDesktop, context)), + if (buttonHeight != null && buttonHeight == ButtonHeight.s) + const SizedBox(height: 2), + ], + ), + ], + ), ), ), ); From a738c2247ccf64247cad90574b55545f0a0b5c1b Mon Sep 17 00:00:00 2001 From: julian Date: Mon, 12 Jan 2026 20:19:08 -0600 Subject: [PATCH 10/17] refactor create masternode dialog to match app wide look and feel and WIP general masternodes UX upgrades --- .../masternodes/create_masternode_view.dart | 107 ++++++ .../masternodes/masternodes_home_view.dart | 356 ++++-------------- .../sub_widgets/register_masternode_form.dart | 282 ++++++++++++++ lib/route_generator.dart | 19 +- lib/wallets/wallet/impl/firo_wallet.dart | 10 +- 5 files changed, 484 insertions(+), 290 deletions(-) create mode 100644 lib/pages/masternodes/create_masternode_view.dart create mode 100644 lib/pages/masternodes/sub_widgets/register_masternode_form.dart diff --git a/lib/pages/masternodes/create_masternode_view.dart b/lib/pages/masternodes/create_masternode_view.dart new file mode 100644 index 0000000000..0f54e546c4 --- /dev/null +++ b/lib/pages/masternodes/create_masternode_view.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../themes/stack_colors.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import 'sub_widgets/register_masternode_form.dart'; + +class CreateMasternodeView extends ConsumerStatefulWidget { + const CreateMasternodeView({super.key, required this.firoWalletId}); + + static const routeName = "/createMasternodeView"; + + final String firoWalletId; + + @override + ConsumerState createState() => + _CreateMasternodeDialogState(); +} + +class _CreateMasternodeDialogState extends ConsumerState { + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => SizedBox( + width: 660, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: .spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Create masternode", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Flexible( + child: Padding( + padding: const EdgeInsets.only(left: 32, bottom: 32, right: 32), + child: child, + ), + ), + ], + ), + ), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.background, + leading: AppBarBackButton( + onPressed: () async { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Create masternode", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.only(bottom: 16), + child: child, + ), + ), + ), + ), + ); + }, + ), + ), + ), + ), + child: RegisterMasternodeForm(firoWalletId: widget.firoWalletId), + ), + ); + } +} diff --git a/lib/pages/masternodes/masternodes_home_view.dart b/lib/pages/masternodes/masternodes_home_view.dart index e0b5dbe424..3179972617 100644 --- a/lib/pages/masternodes/masternodes_home_view.dart +++ b/lib/pages/masternodes/masternodes_home_view.dart @@ -1,18 +1,19 @@ -import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; + +import '../../providers/global/wallets_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; -import '../../utilities/logger.dart'; +import '../../wallets/wallet/impl/firo_wallet.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/desktop_app_bar.dart'; import '../../widgets/desktop/desktop_scaffold.dart'; -import '../../widgets/stack_dialog.dart'; -import '../../providers/global/wallets_provider.dart'; -import '../../wallets/wallet/impl/firo_wallet.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/dialogs/s_dialog.dart'; +import 'create_masternode_view.dart'; class MasternodesHomeView extends ConsumerStatefulWidget { const MasternodesHomeView({super.key, required this.walletId}); @@ -87,18 +88,20 @@ class _MasternodesHomeViewState extends ConsumerState { ), trailing: Padding( padding: const EdgeInsets.only(right: 24), - child: ElevatedButton.icon( - onPressed: _showCreateMasternodeDialog, - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of( - context, - ).extension()!.buttonBackPrimary, - foregroundColor: Theme.of( - context, - ).extension()!.buttonTextPrimary, + child: PrimaryButton( + label: "Create Masternode", + buttonHeight: .l, + horizontalContentPadding: 10, + icon: SvgPicture.asset( + Assets.svg.circlePlus, + colorFilter: ColorFilter.mode( + Theme.of( + context, + ).extension()!.buttonTextPrimary, + .srcIn, + ), ), - icon: const Icon(Icons.add), - label: const Text('Create Masternode'), + onPressed: _showDesktopCreateMasternodeDialog, ), ), ) @@ -114,11 +117,38 @@ class _MasternodesHomeViewState extends ConsumerState { ), actions: [ Padding( - padding: const EdgeInsets.only(right: 16), - child: IconButton( - onPressed: _showCreateMasternodeDialog, - icon: const Icon(Icons.add), - tooltip: 'Create Masternode', + padding: const EdgeInsets.only( + top: 10, + bottom: 10, + right: 10, + ), + child: AspectRatio( + aspectRatio: 1, + child: AppBarIconButton( + key: const Key("createNewMasterNodeButton"), + size: 36, + shadows: const [], + color: Theme.of( + context, + ).extension()!.background, + icon: SvgPicture.asset( + Assets.svg.plus, + colorFilter: ColorFilter.mode( + Theme.of( + context, + ).extension()!.accentColorDark, + .srcIn, + ), + width: 20, + height: 20, + ), + onPressed: () { + Navigator.of(context).pushNamed( + CreateMasternodeView.routeName, + arguments: widget.walletId, + ); + }, + ), ), ), ], @@ -152,19 +182,27 @@ class _MasternodesHomeViewState extends ConsumerState { "No masternodes found", style: STextStyles.w600_14(context), ), - const SizedBox(height: 16), - ElevatedButton.icon( - onPressed: _showCreateMasternodeDialog, - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of( - context, - ).extension()!.buttonBackPrimary, - foregroundColor: Theme.of( - context, - ).extension()!.buttonTextPrimary, - ), - icon: const Icon(Icons.add), - label: const Text('Create Your First Masternode'), + const SizedBox(height: 24), + Row( + mainAxisSize: .min, + mainAxisAlignment: .center, + children: [ + PrimaryButton( + label: "Create Your First Masternode", + horizontalContentPadding: 16, + buttonHeight: Util.isDesktop ? .l : null, + onPressed: () { + if (Util.isDesktop) { + _showDesktopCreateMasternodeDialog(); + } else { + Navigator.of(context).pushNamed( + CreateMasternodeView.routeName, + arguments: widget.walletId, + ); + } + }, + ), + ], ), ], ), @@ -451,11 +489,12 @@ class _MasternodesHomeViewState extends ConsumerState { ); } - void _showCreateMasternodeDialog() { + void _showDesktopCreateMasternodeDialog() { showDialog( context: context, barrierDismissible: true, - builder: (context) => _CreateMasternodeDialog(wallet: _wallet), + builder: (context) => + SDialog(child: CreateMasternodeView(firoWalletId: widget.walletId)), ); } @@ -468,251 +507,6 @@ class _MasternodesHomeViewState extends ConsumerState { } } -class _CreateMasternodeDialog extends StatefulWidget { - const _CreateMasternodeDialog({required this.wallet}); - - final FiroWallet wallet; - - @override - State<_CreateMasternodeDialog> createState() => - _CreateMasternodeDialogState(); -} - -class _CreateMasternodeDialogState extends State<_CreateMasternodeDialog> { - final GlobalKey _formKey = GlobalKey(); - final TextEditingController _ipAndPortController = TextEditingController(); - final TextEditingController _operatorPubKeyController = - TextEditingController(); - final TextEditingController _votingAddressController = - TextEditingController(); - final TextEditingController _operatorRewardController = TextEditingController( - text: "0", - ); - final TextEditingController _payoutAddressController = - TextEditingController(); - bool _isRegistering = false; - String? _errorMessage; - - @override - void dispose() { - _ipAndPortController.dispose(); - _operatorPubKeyController.dispose(); - _votingAddressController.dispose(); - _operatorRewardController.dispose(); - _payoutAddressController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final stack = Theme.of(context).extension()!; - final spendable = widget.wallet.info.cachedBalance.spendable; - final spendableFiro = spendable.decimal; - final threshold = Decimal.fromInt(1000); - final canRegister = spendableFiro >= threshold; - final availableCount = (spendableFiro ~/ threshold).toInt(); - - return AlertDialog( - backgroundColor: stack.popupBG, - title: const Text('Create Masternode'), - content: SizedBox( - width: 500, - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (!canRegister) - Container( - width: double.infinity, - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: stack.textFieldErrorBG, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - 'Insufficient funds to register a masternode. You need at least 1000 public FIRO.', - style: STextStyles.w600_14( - context, - ).copyWith(color: stack.textDark), - ), - ) - else - Container( - width: double.infinity, - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: stack.textFieldSuccessBG, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - 'You can register $availableCount masternode(s).', - style: STextStyles.w600_14( - context, - ).copyWith(color: stack.textDark), - ), - ), - if (_errorMessage != null) - Container( - width: double.infinity, - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: stack.textFieldErrorBG, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - 'Registration failed: $_errorMessage', - style: STextStyles.w600_14( - context, - ).copyWith(color: stack.textDark), - ), - ), - TextFormField( - controller: _ipAndPortController, - decoration: const InputDecoration( - labelText: 'IP:Port', - hintText: '123.45.67.89:8168', - ), - validator: (v) { - if (v == null || v.trim().isEmpty) return 'Required'; - final parts = v.split(':'); - if (parts.length != 2) return 'Format must be ip:port'; - if (int.tryParse(parts[1]) == null) return 'Invalid port'; - return null; - }, - ), - const SizedBox(height: 8), - TextFormField( - controller: _operatorPubKeyController, - decoration: const InputDecoration( - labelText: 'Operator public key (BLS)', - ), - validator: (v) => - (v == null || v.trim().isEmpty) ? 'Required' : null, - ), - const SizedBox(height: 8), - TextFormField( - controller: _votingAddressController, - decoration: const InputDecoration( - labelText: 'Voting address (optional)', - hintText: 'Defaults to owner address', - ), - ), - const SizedBox(height: 8), - TextFormField( - controller: _operatorRewardController, - decoration: const InputDecoration( - labelText: 'Operator reward (%)', - hintText: '0', - ), - keyboardType: TextInputType.number, - ), - const SizedBox(height: 8), - TextFormField( - controller: _payoutAddressController, - decoration: const InputDecoration(labelText: 'Payout address'), - validator: (v) => - (v == null || v.trim().isEmpty) ? 'Required' : null, - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: _isRegistering ? null : () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: _isRegistering || !canRegister - ? null - : _registerMasternode, - style: FilledButton.styleFrom( - backgroundColor: stack.buttonBackPrimary, - foregroundColor: stack.buttonTextPrimary, - ), - child: _isRegistering - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Create'), - ), - ], - ); - } - - Future _registerMasternode() async { - setState(() { - _isRegistering = true; - _errorMessage = null; // Clear any previous error - }); - - try { - final parts = _ipAndPortController.text.trim().split(':'); - final ip = parts[0]; - final port = int.parse(parts[1]); - final operatorPubKey = _operatorPubKeyController.text.trim(); - final votingAddress = _votingAddressController.text.trim(); - final operatorReward = _operatorRewardController.text.trim().isNotEmpty - ? (double.parse(_operatorRewardController.text.trim()) * 100).floor() - : 0; - final payoutAddress = _payoutAddressController.text.trim(); - - final txId = await widget.wallet.registerMasternode( - ip, - port, - operatorPubKey, - votingAddress, - operatorReward, - payoutAddress, - ); - - if (!mounted) return; - - // Get the parent navigator context before popping - final navigator = Navigator.of(context, rootNavigator: Util.isDesktop); - navigator.pop(); - - Logging.instance.i('Masternode registration submitted: $txId'); - - // Show success dialog after frame is complete to ensure navigation stack is correct - if (!mounted) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - showDialog( - context: context, - barrierDismissible: true, - useRootNavigator: Util.isDesktop, - builder: (_) => StackOkDialog( - title: 'Masternode Registration Submitted', - message: - 'Masternode registration submitted, your masternode will appear in the list after the tx is confirmed.\n\nTransaction ID: $txId', - desktopPopRootNavigator: Util.isDesktop, - ), - ); - }); - } catch (e, s) { - Logging.instance.e( - "Masternode registration failed", - error: e, - stackTrace: s, - ); - - if (!mounted) return; - - setState(() { - _errorMessage = e.toString(); - _isRegistering = false; - }); - } - } -} - class _MasternodeInfoDialog extends StatelessWidget { const _MasternodeInfoDialog({required this.node}); diff --git a/lib/pages/masternodes/sub_widgets/register_masternode_form.dart b/lib/pages/masternodes/sub_widgets/register_masternode_form.dart new file mode 100644 index 0000000000..22f96381f5 --- /dev/null +++ b/lib/pages/masternodes/sub_widgets/register_masternode_form.dart @@ -0,0 +1,282 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/global/wallets_provider.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/amount/amount.dart'; +import '../../../utilities/if_not_already.dart'; +import '../../../utilities/logger.dart'; +import '../../../utilities/show_loading.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../../wallets/wallet/impl/firo_wallet.dart'; +import '../../../widgets/desktop/primary_button.dart'; +import '../../../widgets/desktop/secondary_button.dart'; +import '../../../widgets/rounded_container.dart'; +import '../../../widgets/stack_dialog.dart'; +import '../../../widgets/textfields/adaptive_text_field.dart'; + +class RegisterMasternodeForm extends ConsumerStatefulWidget { + const RegisterMasternodeForm({super.key, required this.firoWalletId}); + + final String firoWalletId; + + @override + ConsumerState createState() => + _RegisterMasternodeFormState(); +} + +class _RegisterMasternodeFormState + extends ConsumerState { + late final Amount _masternodeThreshold; + + final _ipAndPortController = TextEditingController(); + final _operatorPubKeyController = TextEditingController(); + final _votingAddressController = TextEditingController(); + final _operatorRewardController = TextEditingController(text: "0"); + final _payoutAddressController = TextEditingController(); + + TextStyle _getStyle(BuildContext context) { + return Util.isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ) + : STextStyles.smallMed12(context); + } + + late final VoidCallback _register; + + bool _enableCreateButton = false; + + void _validate() { + if (mounted) { + setState(() { + _enableCreateButton = [ + _ipAndPortController.text.trim().isNotEmpty, + _operatorPubKeyController.text.trim().isNotEmpty, + _operatorRewardController.text.trim().isNotEmpty, + _payoutAddressController.text.trim().isNotEmpty, + ].every((e) => e); + }); + } + } + + Future _registerMasternode() async { + final parts = _ipAndPortController.text.trim().split(':'); + final ip = parts[0]; + final port = int.parse(parts[1]); + final operatorPubKey = _operatorPubKeyController.text.trim(); + final votingAddress = _votingAddressController.text.trim(); + final operatorReward = _operatorRewardController.text.trim().isNotEmpty + ? (double.parse(_operatorRewardController.text.trim()) * 100).floor() + : 0; + final payoutAddress = _payoutAddressController.text.trim(); + + final wallet = + ref.read(pWallets).getWallet(widget.firoWalletId) as FiroWallet; + + final txId = await wallet.registerMasternode( + ip, + port, + operatorPubKey, + votingAddress, + operatorReward, + payoutAddress, + ); + + Logging.instance.i('Masternode registration submitted: $txId'); + + return txId; + } + + @override + void initState() { + super.initState(); + final coin = ref.read(pWalletCoin(widget.firoWalletId)); + _masternodeThreshold = Amount.fromDecimal( + kMasterNodeValue, + fractionDigits: coin.fractionDigits, + ); + + _register = IfNotAlreadyAsync(() async { + Exception? ex; + + final txId = await showLoading( + whileFuture: _registerMasternode(), + context: context, + message: "Creating and submitting masternode registration...", + delay: const Duration(seconds: 1), + onException: (e) => ex = e, + ); + + if (mounted) { + final String title; + String message; + if (ex != null || txId == null) { + message = ex?.toString().trim() ?? "Unknown error: txId=$txId"; + const exceptionPrefix = "Exception:"; + while (message.startsWith(exceptionPrefix) && + message.length > exceptionPrefix.length) { + message = message.substring(exceptionPrefix.length).trim(); + } + title = "Registration failed"; + } else { + title = "Masternode Registration Submitted"; + message = + "Masternode registration submitted, your masternode will " + "appear in the list after the tx is confirmed.\n\nTransaction" + " ID: $txId"; + } + + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: title, + message: message, + desktopPopRootNavigator: Util.isDesktop, + maxWidth: Util.isDesktop ? 400 : null, + ), + ); + } + }).execute; + } + + @override + void dispose() { + _ipAndPortController.dispose(); + _operatorPubKeyController.dispose(); + _votingAddressController.dispose(); + _operatorRewardController.dispose(); + _payoutAddressController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final stack = Theme.of(context).extension()!; + final spendableFiro = ref.watch( + pWalletBalance(widget.firoWalletId).select((s) => s.spendable), + ); + final canRegister = spendableFiro >= _masternodeThreshold; + final availableCount = (spendableFiro.raw ~/ _masternodeThreshold.raw) + .toInt(); + + final infoColor = canRegister + ? stack.snackBarTextSuccess + : stack.snackBarTextError; + final infoColorBG = canRegister + ? stack.snackBarBackSuccess + : stack.snackBarBackError; + + final infoMessage = canRegister + ? "You can register $availableCount masternode(s)." + : "Insufficient funds to register a masternode. " + "You need at least 1000 public FIRO."; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: RoundedContainer( + color: infoColorBG, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + infoMessage, + style: STextStyles.w600_14( + context, + ).copyWith(color: infoColor), + ), + ), + ), + ), + ], + ), + ), + + SizedBox(height: Util.isDesktop ? 24 : 16), + + SelectableText("IP:Port", style: _getStyle(context)), + SizedBox(height: Util.isDesktop ? 10 : 8), + AdaptiveTextField( + controller: _ipAndPortController, + showPasteClearButton: true, + maxLines: 1, + onChangedComprehensive: (_) => _validate(), + ), + SizedBox(height: Util.isDesktop ? 24 : 16), + + SelectableText("Operator public key (BLS)", style: _getStyle(context)), + SizedBox(height: Util.isDesktop ? 10 : 8), + AdaptiveTextField( + controller: _operatorPubKeyController, + showPasteClearButton: true, + maxLines: 1, + onChangedComprehensive: (_) => _validate(), + ), + SizedBox(height: Util.isDesktop ? 24 : 16), + + SelectableText("Voting address (optional)", style: _getStyle(context)), + SizedBox(height: Util.isDesktop ? 10 : 8), + AdaptiveTextField( + controller: _votingAddressController, + showPasteClearButton: true, + maxLines: 1, + labelText: "Defaults to owner address", + onChangedComprehensive: (_) => _validate(), + ), + SizedBox(height: Util.isDesktop ? 24 : 16), + + SelectableText("Operator reward (%)", style: _getStyle(context)), + SizedBox(height: Util.isDesktop ? 10 : 8), + AdaptiveTextField( + controller: _operatorRewardController, + showPasteClearButton: true, + maxLines: 1, + onChangedComprehensive: (_) => _validate(), + ), + SizedBox(height: Util.isDesktop ? 24 : 16), + + SelectableText("Payout address", style: _getStyle(context)), + SizedBox(height: Util.isDesktop ? 10 : 8), + AdaptiveTextField( + controller: _payoutAddressController, + showPasteClearButton: true, + maxLines: 1, + onChangedComprehensive: (_) => _validate(), + ), + + Util.isDesktop ? const SizedBox(height: 32) : const Spacer(), + + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + buttonHeight: Util.isDesktop ? .l : null, + ), + ), + SizedBox(width: Util.isDesktop ? 24 : 16), + Expanded( + child: PrimaryButton( + label: "Create", + enabled: _enableCreateButton, + onPressed: _enableCreateButton ? _register : null, + buttonHeight: Util.isDesktop ? .l : null, + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 4961b23ae5..483d829bc5 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -29,8 +29,8 @@ import 'models/keys/key_data_interface.dart'; import 'models/keys/view_only_wallet_data.dart'; import 'models/paynym/paynym_account_lite.dart'; import 'models/send_view_auto_fill_data.dart'; -import 'pages/add_wallet_views/add_token_view/add_custom_token_view.dart'; import 'pages/add_wallet_views/add_token_view/add_custom_solana_token_view.dart'; +import 'pages/add_wallet_views/add_token_view/add_custom_token_view.dart'; import 'pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart'; import 'pages/add_wallet_views/add_wallet_view/add_wallet_view.dart'; import 'pages/add_wallet_views/create_or_restore_wallet_view/create_or_restore_wallet_view.dart'; @@ -44,8 +44,8 @@ import 'pages/add_wallet_views/new_wallet_recovery_phrase_warning_view/new_walle import 'pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart'; import 'pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart'; import 'pages/add_wallet_views/restore_wallet_view/restore_wallet_view.dart'; -import 'pages/add_wallet_views/select_wallet_for_token_view.dart'; import 'pages/add_wallet_views/select_wallet_for_sol_token_view.dart'; +import 'pages/add_wallet_views/select_wallet_for_token_view.dart'; import 'pages/add_wallet_views/verify_recovery_phrase_view/verify_recovery_phrase_view.dart'; import 'pages/address_book_views/address_book_view.dart'; import 'pages/address_book_views/subviews/add_address_book_entry_view.dart'; @@ -77,6 +77,8 @@ import 'pages/generic/single_field_edit_view.dart'; import 'pages/home_view/home_view.dart'; import 'pages/intro_view.dart'; import 'pages/manage_favorites_view/manage_favorites_view.dart'; +import 'pages/masternodes/create_masternode_view.dart'; +import 'pages/masternodes/masternodes_home_view.dart'; import 'pages/monkey/monkey_view.dart'; import 'pages/namecoin_names/buy_domain_view.dart'; import 'pages/namecoin_names/confirm_name_transaction_view.dart'; @@ -155,6 +157,7 @@ import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_setting import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/rbf_settings_view.dart'; import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/rename_wallet_view.dart'; import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_info.dart'; +import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_view_key_view.dart'; import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dart'; import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/xpub_view.dart'; import 'pages/signing/signing_view.dart'; @@ -163,7 +166,6 @@ import 'pages/spark_names/buy_spark_name_view.dart'; import 'pages/spark_names/confirm_spark_name_transaction_view.dart'; import 'pages/spark_names/spark_names_home_view.dart'; import 'pages/spark_names/sub_widgets/spark_name_details.dart'; -import 'pages/masternodes/masternodes_home_view.dart'; import 'pages/special/firo_rescan_recovery_error_dialog.dart'; import 'pages/stack_privacy_calls.dart'; import 'pages/token_view/my_tokens_view.dart'; @@ -235,7 +237,6 @@ import 'wallets/wallet/wallet.dart'; import 'wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; import 'widgets/choose_coin_view.dart'; import 'widgets/frost_scaffold.dart'; -import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_view_key_view.dart'; /* * This file contains all the routes for the app. @@ -908,6 +909,16 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case CreateMasternodeView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => CreateMasternodeView(firoWalletId: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case BuySparkNameView.routeName: if (args is ({String walletId, String name})) { return getRoute( diff --git a/lib/wallets/wallet/impl/firo_wallet.dart b/lib/wallets/wallet/impl/firo_wallet.dart index 7954a7617e..ef93f382e5 100644 --- a/lib/wallets/wallet/impl/firo_wallet.dart +++ b/lib/wallets/wallet/impl/firo_wallet.dart @@ -69,7 +69,7 @@ class MasternodeInfo { }); } -final _masterNodeValue = Decimal.fromInt(1000); // full value (not sats) +final kMasterNodeValue = Decimal.fromInt(1000); // full value (not sats) class FiroWallet extends Bip39HDWallet with @@ -724,7 +724,7 @@ class FiroWallet extends Bip39HDWallet // Fall back to locked in case network call fails blocked = Amount.fromDecimal( - _masterNodeValue, + kMasterNodeValue, fractionDigits: cryptoCurrency.fractionDigits, ).raw == BigInt.from(jsonUTXO["value"] as int); @@ -921,7 +921,7 @@ class FiroWallet extends Bip39HDWallet ) async { if (info.cachedBalance.spendable < Amount.fromDecimal( - _masterNodeValue, + kMasterNodeValue, fractionDigits: cryptoCurrency.fractionDigits, )) { throw Exception( @@ -1074,7 +1074,7 @@ class FiroWallet extends Bip39HDWallet address: collateralAddress.value, addressType: AddressType.p2pkh, amount: Amount.fromDecimal( - _masterNodeValue, + kMasterNodeValue, fractionDigits: cryptoCurrency.fractionDigits, ), isChange: false, @@ -1212,7 +1212,7 @@ class FiroWallet extends Bip39HDWallet final List r = []; final rawMasterNodeAmount = Amount.fromDecimal( - _masterNodeValue, + kMasterNodeValue, fractionDigits: cryptoCurrency.fractionDigits, ).raw.toInt(); From 81a5ca893a9bd4987e4979d4f11dda16b3f90804 Mon Sep 17 00:00:00 2001 From: julian Date: Tue, 13 Jan 2026 10:58:02 -0600 Subject: [PATCH 11/17] show page number (paginated list view) --- lib/widgets/paginated_list_view.dart | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lib/widgets/paginated_list_view.dart b/lib/widgets/paginated_list_view.dart index f4c0a32dfe..96a23f1b0d 100644 --- a/lib/widgets/paginated_list_view.dart +++ b/lib/widgets/paginated_list_view.dart @@ -3,6 +3,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import '../themes/stack_colors.dart'; import '../utilities/assets.dart'; +import '../utilities/text_styles.dart'; import 'custom_buttons/app_bar_icon_button.dart'; enum PageItemPosition { first, last, solo, somewhere } @@ -117,6 +118,7 @@ class _PaginatedListViewState extends State> { const SizedBox(height: 10), Row( mainAxisAlignment: .center, + crossAxisAlignment: .center, children: [ IconButton( color: Theme.of( @@ -129,6 +131,7 @@ class _PaginatedListViewState extends State> { onPressed: _currentPage > 0 ? _firstPage : null, tooltip: "First page", ), + const SizedBox(width: 8), AppBarIconButton( icon: Transform.flip( flipX: true, @@ -145,6 +148,19 @@ class _PaginatedListViewState extends State> { tooltip: "Previous page", onPressed: _currentPage > 0 ? _previousPage : null, ), + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + "${_currentPage + 1} / $_totalPages", + style: STextStyles.w500_14(context).copyWith( + color: Theme.of( + context, + ).extension()!.topNavIconPrimary.withAlpha(190), + ), + ), + ), + AppBarIconButton( icon: SvgPicture.asset( Assets.svg.chevronRight, @@ -158,6 +174,7 @@ class _PaginatedListViewState extends State> { tooltip: "Next page", onPressed: _currentPage < _totalPages - 1 ? _nextPage : null, ), + const SizedBox(width: 8), IconButton( color: Theme.of( context, From 73b5a89f553c22e62f7e7ca3a0fde6a3b7968089 Mon Sep 17 00:00:00 2001 From: julian Date: Tue, 13 Jan 2026 12:30:51 -0600 Subject: [PATCH 12/17] extract master node info into separate widgets and improve look and feel --- .../masternodes/masternodes_home_view.dart | 131 +----------------- .../sub_widgets/masternode_info_widget.dart | 85 ++++++++++++ lib/wallets/wallet/impl/firo_wallet.dart | 24 +++- 3 files changed, 111 insertions(+), 129 deletions(-) create mode 100644 lib/pages/masternodes/sub_widgets/masternode_info_widget.dart diff --git a/lib/pages/masternodes/masternodes_home_view.dart b/lib/pages/masternodes/masternodes_home_view.dart index 3179972617..013aa56436 100644 --- a/lib/pages/masternodes/masternodes_home_view.dart +++ b/lib/pages/masternodes/masternodes_home_view.dart @@ -14,6 +14,7 @@ import '../../widgets/desktop/desktop_scaffold.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/dialogs/s_dialog.dart'; import 'create_masternode_view.dart'; +import 'sub_widgets/masternode_info_widget.dart'; class MasternodesHomeView extends ConsumerStatefulWidget { const MasternodesHomeView({super.key, required this.walletId}); @@ -502,134 +503,8 @@ class _MasternodesHomeViewState extends ConsumerState { showDialog( context: context, barrierDismissible: true, - builder: (context) => _MasternodeInfoDialog(node: node), - ); - } -} - -class _MasternodeInfoDialog extends StatelessWidget { - const _MasternodeInfoDialog({required this.node}); - - final MasternodeInfo node; - - @override - Widget build(BuildContext context) { - final stack = Theme.of(context).extension()!; - final status = node.revocationReason == 0 ? 'Active' : 'Revoked'; - - return AlertDialog( - backgroundColor: stack.popupBG, - title: const Text('Masternode Information'), - content: SizedBox( - width: 500, - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _buildInfoRow(context, 'ProTx Hash', node.proTxHash), - _buildInfoRow( - context, - 'IP:Port', - '${node.serviceAddr}:${node.servicePort}', - ), - _buildInfoRow(context, 'Status', status), - _buildInfoRow( - context, - 'Registered Height', - node.registeredHeight.toString(), - ), - _buildInfoRow( - context, - 'Last Paid Height', - node.lastPaidHeight.toString(), - ), - _buildInfoRow(context, 'Payout Address', node.payoutAddress), - _buildInfoRow(context, 'Owner Address', node.ownerAddress), - _buildInfoRow(context, 'Voting Address', node.votingAddress), - _buildInfoRow( - context, - 'Operator Public Key', - node.pubKeyOperator, - ), - _buildInfoRow( - context, - 'Operator Reward', - '${node.operatorReward / 100} %', - ), - _buildInfoRow(context, 'Collateral Hash', node.collateralHash), - _buildInfoRow( - context, - 'Collateral Index', - node.collateralIndex.toString(), - ), - _buildInfoRow( - context, - 'Collateral Address', - node.collateralAddress, - ), - _buildInfoRow( - context, - 'Pose Penalty', - node.posePenalty.toString(), - ), - _buildInfoRow( - context, - 'Pose Revived Height', - node.poseRevivedHeight.toString(), - ), - _buildInfoRow( - context, - 'Pose Ban Height', - node.poseBanHeight.toString(), - ), - _buildInfoRow( - context, - 'Revocation Reason', - node.revocationReason.toString(), - ), - ], - ), - ), - ), - actions: [ - FilledButton( - onPressed: () => Navigator.of(context).pop(), - style: FilledButton.styleFrom( - backgroundColor: stack.buttonBackPrimary, - foregroundColor: stack.buttonTextPrimary, - ), - child: const Text('Close'), - ), - ], - ); - } - - Widget _buildInfoRow(BuildContext context, String label, String value) { - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: STextStyles.w600_14(context).copyWith( - color: Theme.of(context).extension()!.textSubtitle1, - ), - ), - const SizedBox(height: 4), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular(8), - ), - child: Text(value, style: STextStyles.w500_12(context)), - ), - ], + builder: (context) => SDialog( + child: SizedBox(width: 600, child: MasternodeInfoWidget(info: node)), ), ); } diff --git a/lib/pages/masternodes/sub_widgets/masternode_info_widget.dart b/lib/pages/masternodes/sub_widgets/masternode_info_widget.dart new file mode 100644 index 0000000000..c25b696f12 --- /dev/null +++ b/lib/pages/masternodes/sub_widgets/masternode_info_widget.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +import '../../../themes/stack_colors.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../../wallets/wallet/impl/firo_wallet.dart'; +import '../../../widgets/conditional_parent.dart'; +import '../../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../../widgets/detail_item.dart'; +import '../../../widgets/rounded_white_container.dart'; + +class MasternodeInfoWidget extends StatelessWidget { + const MasternodeInfoWidget({super.key, required this.info}); + + final MasternodeInfo info; + + @override + Widget build(BuildContext context) { + final map = info.pretty(); + final keys = map.keys.toList(growable: false); + + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => Column( + crossAxisAlignment: .stretch, + mainAxisSize: .min, + children: [ + // not really the place for this in terms of structure but running + // out of time... + Row( + mainAxisAlignment: .spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Masternode details", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Flexible( + child: Padding( + padding: const EdgeInsets.only(left: 32, bottom: 32, right: 32), + child: RoundedWhiteContainer( + padding: .zero, + + // using listview kind of breaks + borderColor: Theme.of( + context, + ).extension()!.backgroundAppBar, + child: child, + ), + ), + ), + ], + ), + child: Column( + mainAxisSize: .min, + children: [ + for (int i = 0; i < keys.length; i++) + Builder( + builder: (context) { + final title = keys[i]; + final detail = map[title]!; + + return Column( + mainAxisSize: .min, + children: [ + if (i > 0) const DetailDivider(), + DetailItem( + title: title, + detail: detail, + horizontal: detail.length < 22, + ), + ], + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/wallets/wallet/impl/firo_wallet.dart b/lib/wallets/wallet/impl/firo_wallet.dart index ef93f382e5..ef689a4773 100644 --- a/lib/wallets/wallet/impl/firo_wallet.dart +++ b/lib/wallets/wallet/impl/firo_wallet.dart @@ -67,6 +67,28 @@ class MasternodeInfo { required this.payoutAddress, required this.pubKeyOperator, }); + + Map pretty() { + return { + "ProTx Hash": proTxHash, + "IP:Port": "$serviceAddr:$servicePort", + "Status": revocationReason == 0 ? "Active" : "Revoked", + "Registered Height": registeredHeight.toString(), + "Last Paid Height": lastPaidHeight.toString(), + "Payout Address": payoutAddress, + "Owner Address": ownerAddress, + "Voting Address": votingAddress, + "Operator Public Key": pubKeyOperator, + "Operator Reward": "$operatorReward %", + "Collateral Hash": collateralHash, + "Collateral Index": collateralIndex.toString(), + "Collateral Address": collateralAddress, + "Pose Penalty": posePenalty.toString(), + "Pose Revived Height": poseRevivedHeight.toString(), + "Pose Ban Height": poseBanHeight.toString(), + "Revocation Reason": revocationReason.toString(), + }; + } } final kMasterNodeValue = Decimal.fromInt(1000); // full value (not sats) @@ -925,7 +947,7 @@ class FiroWallet extends Bip39HDWallet fractionDigits: cryptoCurrency.fractionDigits, )) { throw Exception( - 'Not enough funds to register a masternode. You must have at least 1000 FIRO in your public balance.', + 'Not enough funds to register a master You must have at least 1000 FIRO in your public balance.', ); } From efd17317f2a093bc3a3131846652fcf443b41a68 Mon Sep 17 00:00:00 2001 From: julian Date: Tue, 13 Jan 2026 12:53:29 -0600 Subject: [PATCH 13/17] extract master nodes home view widget builder functions into separate widgets --- .../masternodes/masternodes_home_view.dart | 412 +++--------------- .../sub_widgets/masternodes_list.dart | 126 ++++++ .../masternodes_table_desktop.dart | 182 ++++++++ 3 files changed, 372 insertions(+), 348 deletions(-) create mode 100644 lib/pages/masternodes/sub_widgets/masternodes_list.dart create mode 100644 lib/pages/masternodes/sub_widgets/masternodes_table_desktop.dart diff --git a/lib/pages/masternodes/masternodes_home_view.dart b/lib/pages/masternodes/masternodes_home_view.dart index 013aa56436..c5b631fac9 100644 --- a/lib/pages/masternodes/masternodes_home_view.dart +++ b/lib/pages/masternodes/masternodes_home_view.dart @@ -14,7 +14,8 @@ import '../../widgets/desktop/desktop_scaffold.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/dialogs/s_dialog.dart'; import 'create_masternode_view.dart'; -import 'sub_widgets/masternode_info_widget.dart'; +import 'sub_widgets/masternodes_list.dart'; +import 'sub_widgets/masternodes_table_desktop.dart'; class MasternodesHomeView extends ConsumerStatefulWidget { const MasternodesHomeView({super.key, required this.walletId}); @@ -34,6 +35,15 @@ class _MasternodesHomeViewState extends ConsumerState { FiroWallet get _wallet => ref.read(pWallets).getWallet(widget.walletId) as FiroWallet; + void _showDesktopCreateMasternodeDialog() { + showDialog( + context: context, + barrierDismissible: true, + builder: (context) => + SDialog(child: CreateMasternodeView(firoWalletId: widget.walletId)), + ); + } + @override void initState() { super.initState(); @@ -154,358 +164,64 @@ class _MasternodesHomeViewState extends ConsumerState { ), ], ), - body: _buildMasternodesTable(context), - ); - } - - Widget _buildMasternodesTable(BuildContext context) { - return FutureBuilder>( - future: _masternodesFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snapshot.hasError) { - return Center( - child: Text( - "Failed to load masternodes", - style: STextStyles.w600_14(context), - ), - ); - } - final nodes = snapshot.data ?? const []; - if (nodes.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - "No masternodes found", - style: STextStyles.w600_14(context), - ), - const SizedBox(height: 24), - Row( - mainAxisSize: .min, - mainAxisAlignment: .center, - children: [ - PrimaryButton( - label: "Create Your First Masternode", - horizontalContentPadding: 16, - buttonHeight: Util.isDesktop ? .l : null, - onPressed: () { - if (Util.isDesktop) { - _showDesktopCreateMasternodeDialog(); - } else { - Navigator.of(context).pushNamed( - CreateMasternodeView.routeName, - arguments: widget.walletId, - ); - } - }, - ), - ], - ), - ], - ), - ); - } - - final isDesktop = Util.isDesktop; - final stack = Theme.of(context).extension()!; - - if (isDesktop) { - return _buildDesktopTable(nodes, stack); - } else { - return _buildMobileTable(nodes, stack); - } - }, - ); - } - - Widget _buildDesktopTable(List nodes, StackColors stack) { - return Container( - color: stack.textFieldDefaultBG, - child: Column( - children: [ - // Fixed header - Container( - height: 56, - color: stack.textFieldDefaultBG, - child: Row( - children: [ - const Expanded( - flex: 2, - child: Align( - alignment: Alignment.centerRight, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: Text('IP'), - ), - ), - ), - const Expanded( - flex: 2, - child: Align( - alignment: Alignment.centerRight, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: Text('Last Paid Height'), - ), - ), - ), - const Expanded( - flex: 2, - child: Align( - alignment: Alignment.centerRight, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: Text('Status'), - ), + body: FutureBuilder>( + future: _masternodesFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center( + child: Text( + "Failed to load masternodes", + style: STextStyles.w600_14(context), + ), + ); + } + final nodes = snapshot.data ?? const []; + if (nodes.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "No masternodes found", + style: STextStyles.w600_14(context), ), - ), - Expanded(flex: 3, child: Container()), - ], - ), - ), - // Scrollable content - Expanded( - child: Container( - width: double.infinity, - color: stack.textFieldDefaultBG, - child: SingleChildScrollView( - scrollDirection: Axis.vertical, - child: Column( - children: nodes.map((node) { - final status = node.revocationReason == 0 - ? 'Active' - : 'Revoked'; - return SizedBox( - height: 48, - child: Row( - children: [ - Expanded( - flex: 2, - child: Align( - alignment: Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - ), - child: Text( - node.serviceAddr, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ), - Expanded( - flex: 2, - child: Align( - alignment: Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - ), - child: Text( - node.lastPaidHeight.toString(), - overflow: TextOverflow.ellipsis, - ), - ), - ), - ), - Expanded( - flex: 2, - child: Align( - alignment: Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - ), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: status.toLowerCase() == 'active' - ? stack.accentColorGreen - : stack.accentColorRed, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - status.toUpperCase(), - style: STextStyles.w600_12( - context, - ).copyWith(color: stack.textWhite), - ), - ), - ), - ), - ), - Expanded( - flex: 3, - child: Align( - alignment: Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: () => - _showMasternodeInfoDialog(node), - icon: const Icon(Icons.info_outline), - tooltip: 'View Details', - ), - ], - ), - ), - ), - ), - ], + const SizedBox(height: 24), + Row( + mainAxisSize: .min, + mainAxisAlignment: .center, + children: [ + PrimaryButton( + label: "Create Your First Masternode", + horizontalContentPadding: 16, + buttonHeight: Util.isDesktop ? .l : null, + onPressed: () { + if (Util.isDesktop) { + _showDesktopCreateMasternodeDialog(); + } else { + Navigator.of(context).pushNamed( + CreateMasternodeView.routeName, + arguments: widget.walletId, + ); + } + }, ), - ); - }).toList(), - ), + ], + ), + ], ), - ), - ), - ], - ), - ); - } - - Widget _buildMobileTable(List nodes, StackColors stack) { - return Container( - color: stack.textFieldDefaultBG, - child: ListView.separated( - padding: EdgeInsets.zero, - itemCount: nodes.length, - separatorBuilder: (_, __) => const SizedBox(height: 1), - itemBuilder: (context, index) { - final node = nodes[index]; - final status = node.revocationReason == 0 ? 'Active' : 'Revoked'; - - return Container( - width: double.infinity, - color: stack.textFieldDefaultBG, - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: Text( - 'IP: ${node.serviceAddr}', - style: STextStyles.w600_14(context), - overflow: TextOverflow.ellipsis, - ), - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: status.toLowerCase() == 'active' - ? stack.accentColorGreen - : stack.accentColorRed, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - status.toUpperCase(), - style: STextStyles.w600_12( - context, - ).copyWith(color: stack.textWhite), - ), - ), - ], - ), - const SizedBox(height: 8), - _buildMobileRow( - 'Last Paid Height', - node.lastPaidHeight.toString(), - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - OutlinedButton.icon( - onPressed: () => _showMasternodeInfoDialog(node), - icon: const Icon(Icons.info_outline), - label: const Text('Details'), - style: OutlinedButton.styleFrom( - backgroundColor: stack.textFieldDefaultBG, - foregroundColor: stack.buttonTextSecondary, - side: BorderSide( - color: stack.buttonBackBorderSecondary, - ), - ), - ), - ], - ), - ], - ), - ); + ); + } + + if (Util.isDesktop) { + return MasternodesTableDesktop(nodes: nodes); + } else { + return MasternodesList(nodes: nodes); + } }, ), ); } - - Widget _buildMobileRow(String label, String value) { - return Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 120, - child: Align( - alignment: Alignment.centerLeft, - child: Text( - '$label:', - style: STextStyles.w500_12(context).copyWith( - color: Theme.of( - context, - ).extension()!.textSubtitle1, - ), - ), - ), - ), - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: Text(value, style: STextStyles.w500_12(context)), - ), - ), - ], - ), - ); - } - - void _showDesktopCreateMasternodeDialog() { - showDialog( - context: context, - barrierDismissible: true, - builder: (context) => - SDialog(child: CreateMasternodeView(firoWalletId: widget.walletId)), - ); - } - - void _showMasternodeInfoDialog(MasternodeInfo node) { - showDialog( - context: context, - barrierDismissible: true, - builder: (context) => SDialog( - child: SizedBox(width: 600, child: MasternodeInfoWidget(info: node)), - ), - ); - } } diff --git a/lib/pages/masternodes/sub_widgets/masternodes_list.dart b/lib/pages/masternodes/sub_widgets/masternodes_list.dart new file mode 100644 index 0000000000..7f30c23cbb --- /dev/null +++ b/lib/pages/masternodes/sub_widgets/masternodes_list.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; + +import '../../../themes/stack_colors.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../wallets/wallet/impl/firo_wallet.dart'; + +class MasternodesList extends StatelessWidget { + const MasternodesList({super.key, required this.nodes}); + + final List nodes; + + @override + Widget build(BuildContext context) { + final stack = Theme.of(context).extension()!; + return Container( + color: stack.textFieldDefaultBG, + child: ListView.separated( + padding: EdgeInsets.zero, + itemCount: nodes.length, + separatorBuilder: (_, __) => const SizedBox(height: 1), + itemBuilder: (context, index) { + final node = nodes[index]; + final status = node.revocationReason == 0 ? 'Active' : 'Revoked'; + + return Container( + width: double.infinity, + color: stack.textFieldDefaultBG, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'IP: ${node.serviceAddr}', + style: STextStyles.w600_14(context), + overflow: TextOverflow.ellipsis, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: status.toLowerCase() == 'active' + ? stack.accentColorGreen + : stack.accentColorRed, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + status.toUpperCase(), + style: STextStyles.w600_12( + context, + ).copyWith(color: stack.textWhite), + ), + ), + ], + ), + const SizedBox(height: 8), + _buildMobileRow( + 'Last Paid Height', + node.lastPaidHeight.toString(), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton.icon( + onPressed: () => _showMasternodeInfoDialog(node), + icon: const Icon(Icons.info_outline), + label: const Text('Details'), + style: OutlinedButton.styleFrom( + backgroundColor: stack.textFieldDefaultBG, + foregroundColor: stack.buttonTextSecondary, + side: BorderSide( + color: stack.buttonBackBorderSecondary, + ), + ), + ), + ], + ), + ], + ), + ); + }, + ), + ); + } + + Widget _buildMobileRow(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Align( + alignment: Alignment.centerLeft, + child: Text( + '$label:', + style: STextStyles.w500_12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ), + ), + ), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Text(value, style: STextStyles.w500_12(context)), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/masternodes/sub_widgets/masternodes_table_desktop.dart b/lib/pages/masternodes/sub_widgets/masternodes_table_desktop.dart new file mode 100644 index 0000000000..3b3727d892 --- /dev/null +++ b/lib/pages/masternodes/sub_widgets/masternodes_table_desktop.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; + +import '../../../themes/stack_colors.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../wallets/wallet/impl/firo_wallet.dart'; +import '../../../widgets/dialogs/s_dialog.dart'; +import 'masternode_info_widget.dart'; + +class MasternodesTableDesktop extends StatelessWidget { + const MasternodesTableDesktop({super.key, required this.nodes}); + + final List nodes; + + @override + Widget build(BuildContext context) { + final stack = Theme.of(context).extension()!; + return Container( + color: stack.textFieldDefaultBG, + child: Column( + children: [ + // Fixed header + Container( + height: 56, + color: stack.textFieldDefaultBG, + child: Row( + children: [ + const Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text('IP'), + ), + ), + ), + const Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text('Last Paid Height'), + ), + ), + ), + const Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text('Status'), + ), + ), + ), + Expanded(flex: 3, child: Container()), + ], + ), + ), + // Scrollable content + Expanded( + child: Container( + width: double.infinity, + color: stack.textFieldDefaultBG, + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Column( + children: nodes.map((node) { + final status = node.revocationReason == 0 + ? 'Active' + : 'Revoked'; + return SizedBox( + height: 48, + child: Row( + children: [ + Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Text( + node.serviceAddr, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Text( + node.lastPaidHeight.toString(), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: status.toLowerCase() == 'active' + ? stack.accentColorGreen + : stack.accentColorRed, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + status.toUpperCase(), + style: STextStyles.w600_12( + context, + ).copyWith(color: stack.textWhite), + ), + ), + ), + ), + ), + Expanded( + flex: 3, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () { + showDialog( + context: context, + barrierDismissible: true, + builder: (context) => SDialog( + child: SizedBox( + width: 600, + child: MasternodeInfoWidget( + info: node, + ), + ), + ), + ); + }, + icon: const Icon(Icons.info_outline), + tooltip: 'View Details', + ), + ], + ), + ), + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ), + ), + ], + ), + ); + } +} From 16ea6f7e3e64e028bba08928ccc814d03cd1544c Mon Sep 17 00:00:00 2001 From: julian Date: Tue, 13 Jan 2026 13:34:38 -0600 Subject: [PATCH 14/17] WIP mobile masternodes list --- .../masternodes/masternode_details_view.dart | 57 +++++++ .../masternodes/masternodes_home_view.dart | 12 +- .../sub_widgets/masternodes_list.dart | 143 ++++++------------ lib/route_generator.dart | 12 ++ 4 files changed, 121 insertions(+), 103 deletions(-) create mode 100644 lib/pages/masternodes/masternode_details_view.dart diff --git a/lib/pages/masternodes/masternode_details_view.dart b/lib/pages/masternodes/masternode_details_view.dart new file mode 100644 index 0000000000..ebc3ea2d48 --- /dev/null +++ b/lib/pages/masternodes/masternode_details_view.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +import '../../themes/stack_colors.dart'; +import '../../utilities/text_styles.dart'; +import '../../wallets/wallet/impl/firo_wallet.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import 'sub_widgets/masternode_info_widget.dart'; + +class MasternodeDetailsView extends StatelessWidget { + const MasternodeDetailsView({super.key, required this.node}); + + static const String routeName = "/masternodeDetailsView"; + + final MasternodeInfo node; + + @override + Widget build(BuildContext context) { + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: const AppBarBackButton(), + title: Text( + "Masternode details", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Column( + mainAxisSize: .min, + children: [ + MasternodeInfoWidget(info: node), + const SizedBox(height: 16), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pages/masternodes/masternodes_home_view.dart b/lib/pages/masternodes/masternodes_home_view.dart index c5b631fac9..37ab2fdb95 100644 --- a/lib/pages/masternodes/masternodes_home_view.dart +++ b/lib/pages/masternodes/masternodes_home_view.dart @@ -13,6 +13,7 @@ import '../../widgets/desktop/desktop_app_bar.dart'; import '../../widgets/desktop/desktop_scaffold.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/dialogs/s_dialog.dart'; +import '../../widgets/loading_indicator.dart'; import 'create_masternode_view.dart'; import 'sub_widgets/masternodes_list.dart'; import 'sub_widgets/masternodes_table_desktop.dart'; @@ -32,9 +33,6 @@ class MasternodesHomeView extends ConsumerStatefulWidget { class _MasternodesHomeViewState extends ConsumerState { late Future> _masternodesFuture; - FiroWallet get _wallet => - ref.read(pWallets).getWallet(widget.walletId) as FiroWallet; - void _showDesktopCreateMasternodeDialog() { showDialog( context: context, @@ -47,7 +45,11 @@ class _MasternodesHomeViewState extends ConsumerState { @override void initState() { super.initState(); - _masternodesFuture = _wallet.getMyMasternodes(); + + // TODO polling and update on successful registration + _masternodesFuture = + (ref.read(pWallets).getWallet(widget.walletId) as FiroWallet) + .getMyMasternodes(); } @override @@ -168,7 +170,7 @@ class _MasternodesHomeViewState extends ConsumerState { future: _masternodesFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: LoadingIndicator(height: 50, width: 50)); } if (snapshot.hasError) { return Center( diff --git a/lib/pages/masternodes/sub_widgets/masternodes_list.dart b/lib/pages/masternodes/sub_widgets/masternodes_list.dart index 7f30c23cbb..8df056fba1 100644 --- a/lib/pages/masternodes/sub_widgets/masternodes_list.dart +++ b/lib/pages/masternodes/sub_widgets/masternodes_list.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/text_styles.dart'; import '../../../wallets/wallet/impl/firo_wallet.dart'; +import '../../../widgets/rounded_white_container.dart'; +import '../masternode_details_view.dart'; class MasternodesList extends StatelessWidget { const MasternodesList({super.key, required this.nodes}); @@ -11,113 +13,58 @@ class MasternodesList extends StatelessWidget { @override Widget build(BuildContext context) { - final stack = Theme.of(context).extension()!; - return Container( - color: stack.textFieldDefaultBG, - child: ListView.separated( - padding: EdgeInsets.zero, - itemCount: nodes.length, - separatorBuilder: (_, __) => const SizedBox(height: 1), - itemBuilder: (context, index) { - final node = nodes[index]; - final status = node.revocationReason == 0 ? 'Active' : 'Revoked'; - - return Container( - width: double.infinity, - color: stack.textFieldDefaultBG, - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: Text( - 'IP: ${node.serviceAddr}', - style: STextStyles.w600_14(context), - overflow: TextOverflow.ellipsis, - ), - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: status.toLowerCase() == 'active' - ? stack.accentColorGreen - : stack.accentColorRed, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - status.toUpperCase(), - style: STextStyles.w600_12( - context, - ).copyWith(color: stack.textWhite), - ), - ), - ], - ), - const SizedBox(height: 8), - _buildMobileRow( - 'Last Paid Height', - node.lastPaidHeight.toString(), - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - OutlinedButton.icon( - onPressed: () => _showMasternodeInfoDialog(node), - icon: const Icon(Icons.info_outline), - label: const Text('Details'), - style: OutlinedButton.styleFrom( - backgroundColor: stack.textFieldDefaultBG, - foregroundColor: stack.buttonTextSecondary, - side: BorderSide( - color: stack.buttonBackBorderSecondary, - ), - ), - ), - ], - ), - ], - ), - ); - }, + return ListView.separated( + padding: EdgeInsets.zero, + itemCount: nodes.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _MasternodeCard(node: nodes[index]), ), ); } +} - Widget _buildMobileRow(String label, String value) { - return Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, +// TODO better styling +class _MasternodeCard extends StatelessWidget { + const _MasternodeCard({super.key, required this.node}); + + final MasternodeInfo node; + + @override + Widget build(BuildContext context) { + final stack = Theme.of(context).extension()!; + return RoundedWhiteContainer( + onPressed: () => Navigator.of( + context, + ).pushNamed(MasternodeDetailsView.routeName, arguments: node), + child: Column( + mainAxisSize: .min, children: [ - SizedBox( - width: 120, - child: Align( - alignment: Alignment.centerLeft, - child: Text( - '$label:', - style: STextStyles.w500_12(context).copyWith( - color: Theme.of( + Row( + mainAxisAlignment: .spaceBetween, + children: [ + Text("IP: ${node.serviceAddr}"), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: node.revocationReason == 0 + ? stack.accentColorGreen + : stack.accentColorRed, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + node.revocationReason == 0 ? "ACTIVE" : "REVOKED", + style: STextStyles.w600_12( context, - ).extension()!.textSubtitle1, + ).copyWith(color: stack.textWhite), ), ), - ), + ], ), - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: Text(value, style: STextStyles.w500_12(context)), - ), + Row( + mainAxisAlignment: .spaceBetween, + children: [Text("Last Paid Height: ${node.lastPaidHeight}")], ), ], ), diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 483d829bc5..9cc7a529ad 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -78,6 +78,7 @@ import 'pages/home_view/home_view.dart'; import 'pages/intro_view.dart'; import 'pages/manage_favorites_view/manage_favorites_view.dart'; import 'pages/masternodes/create_masternode_view.dart'; +import 'pages/masternodes/masternode_details_view.dart'; import 'pages/masternodes/masternodes_home_view.dart'; import 'pages/monkey/monkey_view.dart'; import 'pages/namecoin_names/buy_domain_view.dart'; @@ -233,6 +234,7 @@ import 'utilities/enums/add_wallet_type_enum.dart'; import 'wallets/crypto_currency/crypto_currency.dart'; import 'wallets/crypto_currency/intermediate/frost_currency.dart'; import 'wallets/models/tx_data.dart'; +import 'wallets/wallet/impl/firo_wallet.dart'; import 'wallets/wallet/wallet.dart'; import 'wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart'; import 'widgets/choose_coin_view.dart'; @@ -919,6 +921,16 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case MasternodeDetailsView.routeName: + if (args is MasternodeInfo) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => MasternodeDetailsView(node: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case BuySparkNameView.routeName: if (args is ({String walletId, String name})) { return getRoute( From fdd617ace756041f3542c466d0d982a9ff41fb52 Mon Sep 17 00:00:00 2001 From: julian Date: Tue, 13 Jan 2026 15:41:47 -0600 Subject: [PATCH 15/17] hack in quick fix alt to ensure future doesn't start executing before the loading screen is displayed --- lib/utilities/show_loading.dart | 35 +++++++++++++++++---------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/lib/utilities/show_loading.dart b/lib/utilities/show_loading.dart index 040bc23037..39537e37d1 100644 --- a/lib/utilities/show_loading.dart +++ b/lib/utilities/show_loading.dart @@ -16,22 +16,15 @@ import '../themes/stack_colors.dart'; import '../widgets/custom_loading_overlay.dart'; import 'logger.dart'; -Future minWaitFuture( - Future future, { - required Duration delay, -}) async { - final results = await Future.wait( - [ - future, - Future.delayed(delay), - ], - ); +Future minWaitFuture(Future future, {required Duration delay}) async { + final results = await Future.wait([future, Future.delayed(delay)]); return results.first as T; } Future showLoading({ - required Future whileFuture, + Future? whileFuture, + Future Function()? whileFutureAlt, required BuildContext context, required String message, String? subMessage, @@ -40,6 +33,12 @@ Future showLoading({ void Function(Exception)? onException, Duration? delay, }) async { + assert( + (whileFuture != null || whileFutureAlt != null) && + !(whileFuture != null && whileFutureAlt != null) && + !(whileFuture == null && whileFutureAlt == null), + ); + unawaited( showDialog( context: context, @@ -47,10 +46,9 @@ Future showLoading({ builder: (_) => WillPopScope( onWillPop: () async => false, child: Container( - color: Theme.of(context) - .extension()! - .overlay - .withOpacity(opaqueBG ? 1.0 : 0.6), + color: Theme.of( + context, + ).extension()!.overlay.withOpacity(opaqueBG ? 1.0 : 0.6), child: CustomLoadingOverlay( message: message, subMessage: subMessage, @@ -66,9 +64,12 @@ Future showLoading({ try { if (delay != null) { - result = await minWaitFuture(whileFuture, delay: delay); + result = await minWaitFuture( + whileFutureAlt?.call() ?? whileFuture!, + delay: delay, + ); } else { - result = await whileFuture; + result = await (whileFutureAlt?.call() ?? whileFuture!); } } catch (e, s) { Logging.instance.w("showLoading caught: ", error: e, stackTrace: s); From c52cdfbde05f22fc7d7d1dd289370859c1b806fd Mon Sep 17 00:00:00 2001 From: julian Date: Tue, 13 Jan 2026 16:10:14 -0600 Subject: [PATCH 16/17] fix message bug I introduced earlier --- lib/wallets/wallet/impl/firo_wallet.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/wallets/wallet/impl/firo_wallet.dart b/lib/wallets/wallet/impl/firo_wallet.dart index ef689a4773..64e7966ee5 100644 --- a/lib/wallets/wallet/impl/firo_wallet.dart +++ b/lib/wallets/wallet/impl/firo_wallet.dart @@ -947,7 +947,8 @@ class FiroWallet extends Bip39HDWallet fractionDigits: cryptoCurrency.fractionDigits, )) { throw Exception( - 'Not enough funds to register a master You must have at least 1000 FIRO in your public balance.', + 'Not enough funds to register a masternode. ' + 'You must have at least 1000 FIRO in your public balance.', ); } From a088e2c06145d6517909f7f3d940b68e343e07d8 Mon Sep 17 00:00:00 2001 From: julian Date: Tue, 13 Jan 2026 16:21:58 -0600 Subject: [PATCH 17/17] various masternode related fixes and improvements --- .../masternodes/create_masternode_view.dart | 16 +- .../masternodes/masternodes_home_view.dart | 45 +++++- .../sub_widgets/register_masternode_form.dart | 137 ++++++++++-------- lib/wallets/wallet/impl/firo_wallet.dart | 4 +- 4 files changed, 129 insertions(+), 73 deletions(-) diff --git a/lib/pages/masternodes/create_masternode_view.dart b/lib/pages/masternodes/create_masternode_view.dart index 0f54e546c4..9d2940ef7b 100644 --- a/lib/pages/masternodes/create_masternode_view.dart +++ b/lib/pages/masternodes/create_masternode_view.dart @@ -11,11 +11,16 @@ import '../../widgets/desktop/desktop_dialog_close_button.dart'; import 'sub_widgets/register_masternode_form.dart'; class CreateMasternodeView extends ConsumerStatefulWidget { - const CreateMasternodeView({super.key, required this.firoWalletId}); + const CreateMasternodeView({ + super.key, + required this.firoWalletId, + this.popTxidOnSuccess = true, + }); static const routeName = "/createMasternodeView"; final String firoWalletId; + final bool popTxidOnSuccess; @override ConsumerState createState() => @@ -100,7 +105,14 @@ class _CreateMasternodeDialogState extends ConsumerState { ), ), ), - child: RegisterMasternodeForm(firoWalletId: widget.firoWalletId), + child: RegisterMasternodeForm( + firoWalletId: widget.firoWalletId, + onRegistrationSuccess: (txid) { + if (widget.popTxidOnSuccess && mounted) { + Navigator.of(context, rootNavigator: Util.isDesktop).pop(txid); + } + }, + ), ), ); } diff --git a/lib/pages/masternodes/masternodes_home_view.dart b/lib/pages/masternodes/masternodes_home_view.dart index 37ab2fdb95..6933a5c1ef 100644 --- a/lib/pages/masternodes/masternodes_home_view.dart +++ b/lib/pages/masternodes/masternodes_home_view.dart @@ -5,6 +5,7 @@ import 'package:flutter_svg/svg.dart'; import '../../providers/global/wallets_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; +import '../../utilities/logger.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/wallet/impl/firo_wallet.dart'; @@ -14,6 +15,7 @@ import '../../widgets/desktop/desktop_scaffold.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/dialogs/s_dialog.dart'; import '../../widgets/loading_indicator.dart'; +import '../../widgets/stack_dialog.dart'; import 'create_masternode_view.dart'; import 'sub_widgets/masternodes_list.dart'; import 'sub_widgets/masternodes_table_desktop.dart'; @@ -33,13 +35,40 @@ class MasternodesHomeView extends ConsumerStatefulWidget { class _MasternodesHomeViewState extends ConsumerState { late Future> _masternodesFuture; - void _showDesktopCreateMasternodeDialog() { - showDialog( + Future _showDesktopCreateMasternodeDialog() async { + final txid = await showDialog( context: context, barrierDismissible: true, builder: (context) => SDialog(child: CreateMasternodeView(firoWalletId: widget.walletId)), ); + _handleSuccessTxid(txid); + } + + void _handleSuccessTxid(Object? txid) { + Logging.instance.i( + "$runtimeType _handleSuccessTxid($txid) called where mounted=$mounted", + ); + if (mounted && txid is String) { + setState(() { + _masternodesFuture = + (ref.read(pWallets).getWallet(widget.walletId) as FiroWallet) + .getMyMasternodes(); + }); + + showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Masternode Registration Submitted", + message: + "Masternode registration submitted, your masternode will " + "appear in the list after the tx is confirmed.\n\nTransaction" + " ID: $txid", + desktopPopRootNavigator: Util.isDesktop, + maxWidth: Util.isDesktop ? 400 : null, + ), + ); + } } @override @@ -155,11 +184,12 @@ class _MasternodesHomeViewState extends ConsumerState { width: 20, height: 20, ), - onPressed: () { - Navigator.of(context).pushNamed( + onPressed: () async { + final txid = await Navigator.of(context).pushNamed( CreateMasternodeView.routeName, arguments: widget.walletId, ); + _handleSuccessTxid(txid); }, ), ), @@ -199,14 +229,15 @@ class _MasternodesHomeViewState extends ConsumerState { label: "Create Your First Masternode", horizontalContentPadding: 16, buttonHeight: Util.isDesktop ? .l : null, - onPressed: () { + onPressed: () async { if (Util.isDesktop) { - _showDesktopCreateMasternodeDialog(); + await _showDesktopCreateMasternodeDialog(); } else { - Navigator.of(context).pushNamed( + final txid = await Navigator.of(context).pushNamed( CreateMasternodeView.routeName, arguments: widget.walletId, ); + _handleSuccessTxid(txid); } }, ), diff --git a/lib/pages/masternodes/sub_widgets/register_masternode_form.dart b/lib/pages/masternodes/sub_widgets/register_masternode_form.dart index 22f96381f5..84977d3d44 100644 --- a/lib/pages/masternodes/sub_widgets/register_masternode_form.dart +++ b/lib/pages/masternodes/sub_widgets/register_masternode_form.dart @@ -11,6 +11,7 @@ import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../wallets/wallet/impl/firo_wallet.dart'; +import '../../../widgets/conditional_parent.dart'; import '../../../widgets/desktop/primary_button.dart'; import '../../../widgets/desktop/secondary_button.dart'; import '../../../widgets/rounded_container.dart'; @@ -18,10 +19,16 @@ import '../../../widgets/stack_dialog.dart'; import '../../../widgets/textfields/adaptive_text_field.dart'; class RegisterMasternodeForm extends ConsumerStatefulWidget { - const RegisterMasternodeForm({super.key, required this.firoWalletId}); + const RegisterMasternodeForm({ + super.key, + required this.firoWalletId, + required this.onRegistrationSuccess, + }); final String firoWalletId; + final void Function(String) onRegistrationSuccess; + @override ConsumerState createState() => _RegisterMasternodeFormState(); @@ -53,11 +60,18 @@ class _RegisterMasternodeFormState void _validate() { if (mounted) { + final percent = double.tryParse(_operatorRewardController.text); setState(() { _enableCreateButton = [ - _ipAndPortController.text.trim().isNotEmpty, + _ipAndPortController.text + .trim() + .split(":") + .where((e) => e.isNotEmpty) + .length == + 2, _operatorPubKeyController.text.trim().isNotEmpty, - _operatorRewardController.text.trim().isNotEmpty, + percent != null && !percent.isNegative, + percent != null && percent <= 100.0, _payoutAddressController.text.trim().isNotEmpty, ].every((e) => e); }); @@ -70,11 +84,16 @@ class _RegisterMasternodeFormState final port = int.parse(parts[1]); final operatorPubKey = _operatorPubKeyController.text.trim(); final votingAddress = _votingAddressController.text.trim(); - final operatorReward = _operatorRewardController.text.trim().isNotEmpty - ? (double.parse(_operatorRewardController.text.trim()) * 100).floor() - : 0; final payoutAddress = _payoutAddressController.text.trim(); + // according to https://github.com/cypherstack/stack_wallet/blob/c898a70f808ed5490b8dd23571f5f162d9e38158/lib/wallets/wallet/impl/firo_wallet.dart#L1064 + // this should be a percent of 10000 + final operatorPercent = double.parse(_operatorRewardController.text); + final operatorReward = (10000 * (operatorPercent / 100)).round().clamp( + 0, + 10000, + ); + final wallet = ref.read(pWallets).getWallet(widget.firoWalletId) as FiroWallet; @@ -105,7 +124,7 @@ class _RegisterMasternodeFormState Exception? ex; final txId = await showLoading( - whileFuture: _registerMasternode(), + whileFutureAlt: _registerMasternode, context: context, message: "Creating and submitting masternode registration...", delay: const Duration(seconds: 1), @@ -113,33 +132,25 @@ class _RegisterMasternodeFormState ); if (mounted) { - final String title; - String message; if (ex != null || txId == null) { - message = ex?.toString().trim() ?? "Unknown error: txId=$txId"; + String message = ex?.toString().trim() ?? "Unknown error: txId=$txId"; const exceptionPrefix = "Exception:"; while (message.startsWith(exceptionPrefix) && message.length > exceptionPrefix.length) { message = message.substring(exceptionPrefix.length).trim(); } - title = "Registration failed"; + await showDialog( + context: context, + builder: (_) => StackOkDialog( + title: "Registration failed", + message: message, + desktopPopRootNavigator: Util.isDesktop, + maxWidth: Util.isDesktop ? 400 : null, + ), + ); } else { - title = "Masternode Registration Submitted"; - message = - "Masternode registration submitted, your masternode will " - "appear in the list after the tx is confirmed.\n\nTransaction" - " ID: $txId"; + widget.onRegistrationSuccess.call(txId); } - - await showDialog( - context: context, - builder: (_) => StackOkDialog( - title: title, - message: message, - desktopPopRootNavigator: Util.isDesktop, - maxWidth: Util.isDesktop ? 400 : null, - ), - ); } }).execute; } @@ -180,26 +191,23 @@ class _RegisterMasternodeFormState mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Flexible( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: RoundedContainer( - color: infoColorBG, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - infoMessage, - style: STextStyles.w600_14( - context, - ).copyWith(color: infoColor), - ), + Row( + children: [ + Expanded( + child: RoundedContainer( + color: infoColorBG, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + infoMessage, + style: STextStyles.w600_14( + context, + ).copyWith(color: infoColor), ), ), ), - ], - ), + ), + ], ), SizedBox(height: Util.isDesktop ? 24 : 16), @@ -254,27 +262,32 @@ class _RegisterMasternodeFormState onChangedComprehensive: (_) => _validate(), ), - Util.isDesktop ? const SizedBox(height: 32) : const Spacer(), + Util.isDesktop + ? const SizedBox(height: 32) + : const SizedBox(height: 16), + if (!Util.isDesktop) const Spacer(), - Row( - children: [ - Expanded( - child: SecondaryButton( - label: "Cancel", - onPressed: Navigator.of(context).pop, - buttonHeight: Util.isDesktop ? .l : null, - ), - ), - SizedBox(width: Util.isDesktop ? 24 : 16), - Expanded( - child: PrimaryButton( - label: "Create", - enabled: _enableCreateButton, - onPressed: _enableCreateButton ? _register : null, - buttonHeight: Util.isDesktop ? .l : null, + ConditionalParent( + condition: Util.isDesktop, + builder: (child) => Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + buttonHeight: .l, + ), ), - ), - ], + const SizedBox(width: 24), + Expanded(child: child), + ], + ), + child: PrimaryButton( + label: "Create", + enabled: _enableCreateButton, + onPressed: _enableCreateButton ? _register : null, + buttonHeight: Util.isDesktop ? .l : null, + ), ), ], ); diff --git a/lib/wallets/wallet/impl/firo_wallet.dart b/lib/wallets/wallet/impl/firo_wallet.dart index 64e7966ee5..bbb9f106a8 100644 --- a/lib/wallets/wallet/impl/firo_wallet.dart +++ b/lib/wallets/wallet/impl/firo_wallet.dart @@ -34,7 +34,7 @@ class MasternodeInfo { final String collateralHash; final int collateralIndex; final String collateralAddress; - final int operatorReward; + final double operatorReward; final String serviceAddr; final int servicePort; final int registeredHeight; @@ -1181,7 +1181,7 @@ class FiroWallet extends Bip39HDWallet collateralHash: info["collateralHash"] as String, collateralIndex: info["collateralIndex"] as int, collateralAddress: info["collateralAddress"] as String, - operatorReward: info["operatorReward"] as int, + operatorReward: double.parse(info["operatorReward"].toString()), serviceAddr: (info["state"]["service"] as String).substring( 0, (info["state"]["service"] as String).lastIndexOf(":"),