Merge remote-tracking branch 'origin_SW/staging' into persistence

This commit is contained in:
julian 2023-05-25 15:41:11 -06:00
commit c8b7d3aab8
20 changed files with 754 additions and 453 deletions

@ -1 +1 @@
Subproject commit 81659ce57952c5ab54ffe6bacfbf43da159fff3e
Subproject commit 73d257ed2fe5b204cf3589822e226301b187b86d

View file

@ -70,7 +70,7 @@ final openedFromSWBFileStringStateProvider =
// runs the MyApp widget and checks for new users, caching the value in the
// miscellaneous box for later use
void main() async {
WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
WidgetsFlutterBinding.ensureInitialized();
GoogleFonts.config.allowRuntimeFetching = false;
if (Platform.isIOS) {
Util.libraryPath = await getLibraryDirectory();
@ -179,7 +179,9 @@ void main() async {
}
monero.onStartup();
wownero.onStartup();
if (!Platform.isLinux && !Platform.isWindows) {
wownero.onStartup();
}
// SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
// overlays: [SystemUiOverlay.bottom]);

View file

@ -120,6 +120,8 @@ class _AddWalletViewState extends ConsumerState<AddWalletView> {
if (Platform.isWindows) {
_coins.remove(Coin.monero);
_coins.remove(Coin.wownero);
} else if (Platform.isLinux) {
_coins.remove(Coin.wownero);
}
coinEntities.addAll(_coins.map((e) => CoinEntity(e)));

View file

@ -150,6 +150,7 @@ class _GenerateUriQrCodeViewState extends State<GenerateUriQrCodeView> {
String receivingAddress = widget.receivingAddress;
if ((widget.coin == Coin.bitcoincash ||
widget.coin == Coin.eCash ||
widget.coin == Coin.bitcoincashTestnet) &&
receivingAddress.contains(":")) {
// remove cash addr prefix
@ -246,6 +247,7 @@ class _GenerateUriQrCodeViewState extends State<GenerateUriQrCodeView> {
String receivingAddress = widget.receivingAddress;
if ((widget.coin == Coin.bitcoincash ||
widget.coin == Coin.eCash ||
widget.coin == Coin.bitcoincashTestnet) &&
receivingAddress.contains(":")) {
// remove cash addr prefix

View file

@ -39,6 +39,7 @@ class _StackThemeCardState extends ConsumerState<StackThemeCard> {
late final StreamSubscription<void> _subscription;
late bool _hasTheme;
bool _needsUpdate = false;
String? _cachedSize;
Future<bool> _downloadAndInstall() async {
@ -84,6 +85,7 @@ class _StackThemeCardState extends ConsumerState<StackThemeCard> {
title: message,
onOkPressed: (_) {
setState(() {
_needsUpdate = !result;
_hasTheme = result;
});
},
@ -141,16 +143,21 @@ class _StackThemeCardState extends ConsumerState<StackThemeCard> {
}
}
StackTheme? getInstalled() => ref
.read(mainDBProvider)
.isar
.stackThemes
.where()
.themeIdEqualTo(widget.data.id)
.findFirstSync();
@override
void initState() {
_hasTheme = ref
.read(mainDBProvider)
.isar
.stackThemes
.where()
.themeIdEqualTo(widget.data.id)
.countSync() >
0;
final installedTheme = getInstalled();
_hasTheme = installedTheme != null;
if (_hasTheme) {
_needsUpdate = widget.data.version > (installedTheme?.version ?? 0);
}
_subscription = ref
.read(mainDBProvider)
@ -158,18 +165,16 @@ class _StackThemeCardState extends ConsumerState<StackThemeCard> {
.stackThemes
.watchLazy()
.listen((event) async {
final hasTheme = (await ref
.read(mainDBProvider)
.isar
.stackThemes
.where()
.themeIdEqualTo(widget.data.id)
.count()) >
0;
final installedTheme = getInstalled();
final hasTheme = installedTheme != null;
if (_hasTheme != hasTheme && mounted) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
setState(() {
_hasTheme = hasTheme;
if (hasTheme) {
_needsUpdate =
widget.data.version > (installedTheme.version ?? 0);
}
});
});
}
@ -272,6 +277,16 @@ class _StackThemeCardState extends ConsumerState<StackThemeCard> {
}
},
),
if (_hasTheme && _needsUpdate)
const SizedBox(
height: 12,
),
if (_hasTheme && _needsUpdate)
PrimaryButton(
label: "Update",
buttonHeight: isDesktop ? ButtonHeight.s : ButtonHeight.l,
onPressed: _downloadPressed,
),
const SizedBox(
height: 12,
),

View file

@ -150,6 +150,7 @@ class _AddEditNodeViewState extends ConsumerState<AddEditNodeView> {
case Coin.bitcoincash:
case Coin.litecoin:
case Coin.dogecoin:
case Coin.eCash:
case Coin.firo:
case Coin.namecoin:
case Coin.particl:

View file

@ -619,16 +619,37 @@ class _TransactionDetailsViewState
CustomTextButton(
text: "Info",
onTap: () {
Navigator.of(context)
.pushNamed(
AddressDetailsView
.routeName,
arguments: Tuple2(
_transaction.address
.value!.id,
widget.walletId,
),
);
if (isDesktop) {
showDialog<void>(
context: context,
builder: (_) =>
DesktopDialog(
maxHeight:
double.infinity,
child:
AddressDetailsView(
addressId:
_transaction
.address
.value!
.id,
walletId: widget
.walletId,
),
),
);
} else {
Navigator.of(context)
.pushNamed(
AddressDetailsView
.routeName,
arguments: Tuple2(
_transaction.address
.value!.id,
widget.walletId,
),
);
}
},
)
],
@ -1012,6 +1033,7 @@ class _TransactionDetailsViewState
final String height;
if (widget.coin == Coin.bitcoincash ||
widget.coin == Coin.eCash ||
widget.coin == Coin.bitcoincashTestnet) {
height =
"${_transaction.height != null && _transaction.height! > 0 ? _transaction.height! : "Pending"}";
@ -1129,6 +1151,46 @@ class _TransactionDetailsViewState
],
),
),
if (kDebugMode)
isDesktop
? const _Divider()
: const SizedBox(
height: 12,
),
if (kDebugMode)
RoundedWhiteContainer(
padding: isDesktop
? const EdgeInsets.all(16)
: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
"Tx sub type",
style: isDesktop
? STextStyles
.desktopTextExtraExtraSmall(
context)
: STextStyles.itemSubtitle(context),
),
SelectableText(
_transaction.subType.toString(),
style: isDesktop
? STextStyles
.desktopTextExtraExtraSmall(
context)
.copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.textDark,
)
: STextStyles.itemSubtitle12(context),
),
],
),
),
isDesktop
? const _Divider()
: const SizedBox(

View file

@ -6,7 +6,7 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'package:stackwallet/pages/wallet_view/wallet_view.dart';
import 'package:stackwallet/pages_desktop_specific/my_stack_view/wallet_view/desktop_wallet_view.dart';
import 'package:stackwallet/providers/providers.dart';
import 'package:stackwallet/services/coins/manager.dart';
import 'package:stackwallet/services/coins/firo/firo_wallet.dart';
import 'package:stackwallet/themes/coin_icon_provider.dart';
import 'package:stackwallet/themes/stack_colors.dart';
import 'package:stackwallet/utilities/amount/amount.dart';
@ -24,13 +24,11 @@ class FavoriteCard extends ConsumerStatefulWidget {
required this.walletId,
required this.width,
required this.height,
required this.managerProvider,
}) : super(key: key);
final String walletId;
final double width;
final double height;
final ChangeNotifierProvider<Manager> managerProvider;
@override
ConsumerState<FavoriteCard> createState() => _FavoriteCardState();
@ -38,15 +36,10 @@ class FavoriteCard extends ConsumerStatefulWidget {
class _FavoriteCardState extends ConsumerState<FavoriteCard> {
late final String walletId;
late final ChangeNotifierProvider<Manager> managerProvider;
Amount _cachedBalance = Amount.zero;
Amount _cachedFiatValue = Amount.zero;
@override
void initState() {
walletId = widget.walletId;
managerProvider = widget.managerProvider;
super.initState();
}
@ -55,9 +48,13 @@ class _FavoriteCardState extends ConsumerState<FavoriteCard> {
@override
Widget build(BuildContext context) {
final coin = ref.watch(managerProvider.select((value) => value.coin));
final coin = ref.watch(
walletsChangeNotifierProvider
.select((value) => value.getManager(walletId).coin),
);
final externalCalls = ref.watch(
prefsChangeNotifierProvider.select((value) => value.externalCalls));
prefsChangeNotifierProvider.select((value) => value.externalCalls),
);
return ConditionalParent(
condition: Util.isDesktop,
@ -109,7 +106,10 @@ class _FavoriteCardState extends ConsumerState<FavoriteCard> {
child: GestureDetector(
onTap: () async {
if (coin == Coin.monero || coin == Coin.wownero) {
await ref.read(managerProvider).initializeExisting();
await ref
.read(walletsChangeNotifierProvider)
.getManager(walletId)
.initializeExisting();
}
if (mounted) {
if (Util.isDesktop) {
@ -122,7 +122,9 @@ class _FavoriteCardState extends ConsumerState<FavoriteCard> {
WalletView.routeName,
arguments: Tuple2(
walletId,
managerProvider,
ref
.read(walletsChangeNotifierProvider)
.getManagerProvider(walletId),
),
);
}
@ -205,8 +207,12 @@ class _FavoriteCardState extends ConsumerState<FavoriteCard> {
children: [
Expanded(
child: Text(
ref.watch(managerProvider
.select((value) => value.walletName)),
ref.watch(
walletsChangeNotifierProvider.select(
(value) =>
value.getManager(walletId).walletName,
),
),
style: STextStyles.itemSubtitle12(context).copyWith(
color: Theme.of(context)
.extension<StackColors>()!
@ -225,41 +231,54 @@ class _FavoriteCardState extends ConsumerState<FavoriteCard> {
],
),
),
FutureBuilder(
future: Future(() => ref.watch(managerProvider
.select((value) => value.balance.total))),
builder: (builderContext, AsyncSnapshot<Amount> snapshot) {
if (snapshot.connectionState == ConnectionState.done &&
snapshot.hasData) {
if (snapshot.data != null) {
_cachedBalance = snapshot.data!;
if (externalCalls && _cachedBalance > Amount.zero) {
_cachedFiatValue = (_cachedBalance.decimal *
ref
.watch(
priceAnd24hChangeNotifierProvider
.select(
(value) => value.getPrice(coin),
),
)
.item1)
.toAmount(fractionDigits: 2);
}
}
Builder(
builder: (context) {
final balance = ref.watch(
walletsChangeNotifierProvider.select(
(value) => value.getManager(walletId).balance,
),
);
Amount total = balance.total;
if (coin == Coin.firo || coin == Coin.firoTestNet) {
final balancePrivate = ref.watch(
walletsChangeNotifierProvider.select(
(value) => (value.getManager(walletId).wallet
as FiroWallet)
.balancePrivate,
),
);
total += balancePrivate.total;
}
Amount fiatTotal = Amount.zero;
if (externalCalls && total > Amount.zero) {
fiatTotal = (total.decimal *
ref
.watch(
priceAnd24hChangeNotifierProvider.select(
(value) => value.getPrice(coin),
),
)
.item1)
.toAmount(fractionDigits: 2);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
"${_cachedBalance.localizedStringAsFixed(
"${total.localizedStringAsFixed(
locale: ref.watch(
localeServiceChangeNotifierProvider
.select((value) => value.locale),
localeServiceChangeNotifierProvider.select(
(value) => value.locale,
),
),
decimalPlaces: ref.watch(managerProvider
.select((value) => value.coin.decimals)),
decimalPlaces: coin.decimals,
)} ${coin.ticker}",
style: STextStyles.titleBold12(context).copyWith(
fontSize: 16,
@ -275,15 +294,17 @@ class _FavoriteCardState extends ConsumerState<FavoriteCard> {
),
if (externalCalls)
Text(
"${_cachedFiatValue.localizedStringAsFixed(
"${fiatTotal.localizedStringAsFixed(
locale: ref.watch(
localeServiceChangeNotifierProvider
.select((value) => value.locale),
localeServiceChangeNotifierProvider.select(
(value) => value.locale,
),
),
decimalPlaces: 2,
)} ${ref.watch(
prefsChangeNotifierProvider
.select((value) => value.currency),
prefsChangeNotifierProvider.select(
(value) => value.currency,
),
)}",
style:
STextStyles.itemSubtitle12(context).copyWith(

View file

@ -211,7 +211,6 @@ class _FavoriteWalletsState extends ConsumerState<FavoriteWallets> {
child: FavoriteCard(
key: Key("favCard_$walletId"),
walletId: walletId!,
managerProvider: managerProvider!,
width: cardWidth,
height: cardHeight,
),
@ -219,7 +218,6 @@ class _FavoriteWalletsState extends ConsumerState<FavoriteWallets> {
: FavoriteCard(
key: Key("favCard_$walletId"),
walletId: walletId!,
managerProvider: managerProvider!,
width: cardWidth,
height: cardHeight,
)

View file

@ -74,7 +74,6 @@ class DesktopFavoriteWallets extends ConsumerWidget {
key: Key(walletName),
width: cardWidth,
height: cardHeight,
managerProvider: managerProvider,
);
})
],

View file

@ -58,6 +58,8 @@ class _NodesSettings extends ConsumerState<NodesSettings> {
if (Platform.isWindows) {
_coins.remove(Coin.monero);
_coins.remove(Coin.wownero);
} else if (Platform.isLinux) {
_coins.remove(Coin.wownero);
}
searchNodeController = TextEditingController();

View file

@ -47,7 +47,7 @@ import 'package:stackwallet/widgets/crypto_notifications.dart';
import 'package:tuple/tuple.dart';
import 'package:uuid/uuid.dart';
const int MINIMUM_CONFIRMATIONS = 1;
const int MINIMUM_CONFIRMATIONS = 0;
const String GENESIS_HASH_MAINNET =
"000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f";
@ -361,9 +361,9 @@ class ECashWallet extends CoinServiceAPI
print("format $format");
}
if (_coin == Coin.bitcoincashTestnet) {
return true;
}
// if (_coin == Coin.bitcoincashTestnet) {
// return true;
// }
if (format == bitbox.Address.formatCashAddr) {
return validateCashAddr(address);
@ -1897,7 +1897,7 @@ class ECashWallet extends CoinServiceAPI
required List<int> satoshiAmounts,
}) async {
final builder = bitbox.Bitbox.transactionBuilder(
testnet: coin == Coin.bitcoincashTestnet,
testnet: false, //coin == Coin.bitcoincashTestnet,
);
// retrieve address' utxos from the rest api

View file

@ -405,7 +405,7 @@ Future<Map<dynamic, dynamic>> staticProcessRestore(
tx = null;
}
if (tx == null) {
if (tx == null || tx.subType == isar_models.TransactionSubType.join) {
// This is a jmint.
return;
}
@ -1928,12 +1928,13 @@ class FiroWallet extends CoinServiceAPI
if ((await db
.getTransactions(walletId)
.filter()
.txidMatches(txid)
.findFirst()) ==
null) {
.txidEqualTo(txid)
.count()) ==
0) {
Logging.instance.log(
" txid not found in address history already ${transaction['tx_hash']}",
level: LogLevel.Info);
" txid not found in address history already ${transaction['tx_hash']}",
level: LogLevel.Info,
);
needsRefresh = true;
break;
}
@ -1956,79 +1957,27 @@ class FiroWallet extends CoinServiceAPI
final currentChainHeight = await chainHeight;
final txTxns = await db
.getTransactions(walletId)
.filter()
.isLelantusIsNull()
.or()
.isLelantusEqualTo(false)
.findAll();
final ltxTxns = await db
.getTransactions(walletId)
.filter()
.isLelantusEqualTo(true)
.findAll();
final txCount = await db.getTransactions(walletId).count();
for (isar_models.Transaction tx in txTxns) {
isar_models.Transaction? lTx;
try {
lTx = ltxTxns.firstWhere((e) => e.txid == tx.txid);
} catch (_) {
lTx = null;
}
const paginateLimit = 50;
if (tx.isConfirmed(currentChainHeight, MINIMUM_CONFIRMATIONS)) {
if (txTracker.wasNotifiedPending(tx.txid) &&
!txTracker.wasNotifiedConfirmed(tx.txid)) {
for (int i = 0; i < txCount; i += paginateLimit) {
final transactions = await db
.getTransactions(walletId)
.offset(i)
.limit(paginateLimit)
.findAll();
for (final tx in transactions) {
if (tx.isConfirmed(currentChainHeight, MINIMUM_CONFIRMATIONS)) {
// get all transactions that were notified as pending but not as confirmed
unconfirmedTxnsToNotifyConfirmed.add(tx);
}
if (lTx != null &&
(lTx.inputs.isEmpty || lTx.inputs.first.txid.isEmpty) &&
lTx.isConfirmed(currentChainHeight, MINIMUM_CONFIRMATIONS) ==
false &&
tx.type == isar_models.TransactionType.incoming) {
// If this is a received that is past 1 or more confirmations and has not been minted,
if (!txTracker.wasNotifiedPending(tx.txid)) {
unconfirmedTxnsToNotifyPending.add(tx);
}
}
} else {
if (!txTracker.wasNotifiedPending(tx.txid)) {
// get all transactions that were not notified as pending yet
unconfirmedTxnsToNotifyPending.add(tx);
}
}
}
for (isar_models.Transaction tx in txTxns) {
if (!tx.isConfirmed(currentChainHeight, MINIMUM_CONFIRMATIONS) &&
tx.inputs.first.txid.isNotEmpty) {
// Get all normal txs that are at 0 confirmations
unconfirmedTxnsToNotifyPending
.removeWhere((e) => e.txid == tx.inputs.first.txid);
Logging.instance.log("removed tx: ${tx.txid}", level: LogLevel.Info);
}
}
for (isar_models.Transaction lTX in ltxTxns) {
isar_models.Transaction? tx;
try {
tx = ltxTxns.firstWhere((e) => e.txid == lTX.txid);
} catch (_) {
tx = null;
}
if (tx == null) {
// if this is a ltx transaction that is unconfirmed and not represented in the normal transaction set.
if (!lTX.isConfirmed(currentChainHeight, MINIMUM_CONFIRMATIONS)) {
if (!txTracker.wasNotifiedPending(lTX.txid)) {
unconfirmedTxnsToNotifyPending.add(lTX);
if (txTracker.wasNotifiedPending(tx.txid) &&
!txTracker.wasNotifiedConfirmed(tx.txid)) {
unconfirmedTxnsToNotifyConfirmed.add(tx);
}
} else {
if (txTracker.wasNotifiedPending(lTX.txid) &&
!txTracker.wasNotifiedConfirmed(lTX.txid)) {
unconfirmedTxnsToNotifyConfirmed.add(lTX);
// get all transactions that were not notified as pending yet
if (!txTracker.wasNotifiedPending(tx.txid)) {
unconfirmedTxnsToNotifyPending.add(tx);
}
}
}
@ -2604,7 +2553,7 @@ class FiroWallet extends CoinServiceAPI
var tmpTotal = total;
var index = 1;
var mints = <Map<String, dynamic>>[];
final nextFreeMintIndex = firoGetMintIndex()!;
final nextFreeMintIndex = firoGetMintIndex();
while (tmpTotal > 0) {
final mintValue = min(tmpTotal, MINT_LIMIT);
final mint = await _getMintHex(
@ -2769,7 +2718,7 @@ class FiroWallet extends CoinServiceAPI
amount += utxosToUse[i].value;
}
final index = firoGetMintIndex()!;
final index = firoGetMintIndex();
Logging.instance.log("index of mint $index", level: LogLevel.Info);
for (var mintsElement in mintsMap) {
@ -3036,7 +2985,7 @@ class FiroWallet extends CoinServiceAPI
// if a jmint was made add it to the unspent coin index
LelantusCoin jmint = LelantusCoin(
index!,
index,
transactionInfo['jmintValue'] as int? ?? 0,
transactionInfo['publicCoin'] as String,
transactionInfo['txid'] as String,
@ -3213,17 +3162,18 @@ class FiroWallet extends CoinServiceAPI
);
}
//TODO call get transaction and check each tx to see if it is a "received" tx?
Future<int> _getReceivedTxCount({required String address}) async {
Future<int> _getTxCount({required String address}) async {
try {
final scripthash = AddressUtils.convertToScriptHash(address, _network);
final transactions =
await electrumXClient.getHistory(scripthash: scripthash);
final scriptHash = AddressUtils.convertToScriptHash(address, _network);
final transactions = await electrumXClient.getHistory(
scripthash: scriptHash,
);
return transactions.length;
} catch (e) {
Logging.instance.log(
"Exception rethrown in _getReceivedTxCount(address: $address): $e",
level: LogLevel.Error);
"Exception rethrown in _getReceivedTxCount(address: $address): $e",
level: LogLevel.Error,
);
rethrow;
}
}
@ -3232,8 +3182,7 @@ class FiroWallet extends CoinServiceAPI
try {
final currentReceiving = await _currentReceivingAddress;
final int txCount =
await _getReceivedTxCount(address: currentReceiving.value);
final int txCount = await _getTxCount(address: currentReceiving.value);
Logging.instance.log(
'Number of txs for current receiving address $currentReceiving: $txCount',
level: LogLevel.Info);
@ -3279,8 +3228,7 @@ class FiroWallet extends CoinServiceAPI
Future<void> checkChangeAddressForTransactions() async {
try {
final currentChange = await _currentChangeAddress;
final int txCount =
await _getReceivedTxCount(address: currentChange.value);
final int txCount = await _getTxCount(address: currentChange.value);
Logging.instance.log(
'Number of txs for current change address: $currentChange: $txCount',
level: LogLevel.Info);
@ -3328,27 +3276,13 @@ class FiroWallet extends CoinServiceAPI
.getAddresses(walletId)
.filter()
.not()
.typeEqualTo(isar_models.AddressType.nonWallet)
.and()
.group((q) => q
.subTypeEqualTo(isar_models.AddressSubType.receiving)
.or()
.subTypeEqualTo(isar_models.AddressSubType.change))
.group(
(q) => q
.typeEqualTo(isar_models.AddressType.nonWallet)
.or()
.subTypeEqualTo(isar_models.AddressSubType.nonWallet),
)
.findAll();
// final List<String> allAddresses = [];
// final receivingAddresses =
// DB.instance.get<dynamic>(boxName: walletId, key: 'receivingAddresses')
// as List<dynamic>;
// final changeAddresses =
// DB.instance.get<dynamic>(boxName: walletId, key: 'changeAddresses')
// as List<dynamic>;
//
// for (var i = 0; i < receivingAddresses.length; i++) {
// allAddresses.add(receivingAddresses[i] as String);
// }
// for (var i = 0; i < changeAddresses.length; i++) {
// allAddresses.add(changeAddresses[i] as String);
// }
return allAddresses;
}
@ -3411,252 +3345,451 @@ class FiroWallet extends CoinServiceAPI
final List<isar_models.Address> allAddresses =
await _fetchAllOwnAddresses();
final List<Map<String, dynamic>> allTxHashes =
await _fetchHistory(allAddresses.map((e) => e.value).toList());
List<Map<String, dynamic>> allTransactions = [];
final currentHeight = await chainHeight;
for (final txHash in allTxHashes) {
final storedTx = await db
.getTransactions(walletId)
.filter()
.txidEqualTo(txHash["tx_hash"] as String)
.findFirst();
if (storedTx == null ||
!storedTx.isConfirmed(currentHeight, MINIMUM_CONFIRMATIONS)) {
final tx = await cachedElectrumXClient.getTransaction(
txHash: txHash["tx_hash"] as String,
verbose: true,
coin: coin,
);
if (!_duplicateTxCheck(allTransactions, tx["txid"] as String)) {
tx["address"] = await db
.getAddresses(walletId)
.filter()
.valueEqualTo(txHash["address"] as String)
.findFirst();
tx["height"] = txHash["height"];
allTransactions.add(tx);
}
}
}
final List<Tuple2<isar_models.Transaction, isar_models.Address?>> txnsData =
[];
Set<String> receivingAddresses = allAddresses
.where((e) => e.subType == isar_models.AddressSubType.receiving)
.map((e) => e.value)
.toSet();
Set<String> changeAddresses = allAddresses
.where((e) => e.subType == isar_models.AddressSubType.change)
.map((e) => e.value)
.toSet();
for (final txObject in allTransactions) {
// Logging.instance.log(txObject);
List<String> sendersArray = [];
List<String> recipientsArray = [];
final List<Map<String, dynamic>> allTxHashes =
await _fetchHistory(allAddresses.map((e) => e.value).toList());
// Usually only has value when txType = 'Send'
int inputAmtSentFromWallet = 0;
// Usually has value regardless of txType due to change addresses
int outputAmtAddressedToWallet = 0;
List<Map<String, dynamic>> allTransactions = [];
for (final input in txObject["vin"] as List) {
final address = input["address"] as String?;
if (address != null) {
sendersArray.add(address);
}
}
// final currentHeight = await chainHeight;
// Logging.instance.log("sendersArray: $sendersArray");
for (final txHash in allTxHashes) {
// final storedTx = await db
// .getTransactions(walletId)
// .filter()
// .txidEqualTo(txHash["tx_hash"] as String)
// .findFirst();
for (final output in txObject["vout"] as List) {
final address = output["scriptPubKey"]?["addresses"]?[0] as String? ??
output["scriptPubKey"]?["address"] as String?;
if (address != null) {
recipientsArray.add(address);
}
}
// Logging.instance.log("recipientsArray: $recipientsArray");
final foundInSenders =
allAddresses.any((element) => sendersArray.contains(element.value));
// Logging.instance.log("foundInSenders: $foundInSenders");
String outAddress = "";
int fees = 0;
// If txType = Sent, then calculate inputAmtSentFromWallet, calculate who received how much in aliens array (check outputs)
if (foundInSenders) {
int outAmount = 0;
int inAmount = 0;
bool nFeesUsed = false;
for (final input in txObject["vin"] as List) {
final nFees = input["nFees"];
if (nFees != null) {
nFeesUsed = true;
fees = (Decimal.parse(nFees.toString()) *
Decimal.fromInt(Constants.satsPerCoin(coin)))
.toBigInt()
.toInt();
}
final address = input["address"] as String?;
final value = input["valueSat"] as int?;
if (address != null && value != null) {
if (allAddresses.where((e) => e.value == address).isNotEmpty) {
inputAmtSentFromWallet += value;
}
}
if (value != null) {
inAmount += value;
}
}
for (final output in txObject["vout"] as List) {
final address = output["scriptPubKey"]?["addresses"]?[0] as String? ??
output["scriptPubKey"]?["address"] as String?;
final value = output["value"];
if (value != null) {
outAmount += (Decimal.parse(value.toString()) *
Decimal.fromInt(Constants.satsPerCoin(coin)))
.toBigInt()
.toInt();
if (address != null) {
if (changeAddresses.contains(address)) {
inputAmtSentFromWallet -= (Decimal.parse(value.toString()) *
Decimal.fromInt(Constants.satsPerCoin(coin)))
.toBigInt()
.toInt();
} else {
outAddress = address;
}
}
}
}
fees = nFeesUsed ? fees : inAmount - outAmount;
inputAmtSentFromWallet -= inAmount - outAmount;
} else {
for (final input in txObject["vin"] as List) {
final nFees = input["nFees"];
if (nFees != null) {
fees += (Decimal.parse(nFees.toString()) *
Decimal.fromInt(Constants.satsPerCoin(coin)))
.toBigInt()
.toInt();
}
}
for (final output in txObject["vout"] as List) {
final addresses = output["scriptPubKey"]["addresses"] as List?;
if (addresses != null && addresses.isNotEmpty) {
final address = addresses[0] as String;
final value = output["value"] ?? 0;
// Logging.instance.log(address + value.toString());
if (allAddresses.where((e) => e.value == address).isNotEmpty) {
outputAmtAddressedToWallet += (Decimal.parse(value.toString()) *
Decimal.fromInt(Constants.satsPerCoin(coin)))
.toBigInt()
.toInt();
outAddress = address;
}
}
}
}
isar_models.TransactionType type;
isar_models.TransactionSubType subType =
isar_models.TransactionSubType.none;
int amount;
if (foundInSenders) {
type = isar_models.TransactionType.outgoing;
amount = inputAmtSentFromWallet;
if (txObject["vout"][0]["scriptPubKey"]["type"] == "lelantusmint") {
subType = isar_models.TransactionSubType.mint;
}
} else {
type = isar_models.TransactionType.incoming;
amount = outputAmtAddressedToWallet;
}
final transactionAddress =
allAddresses.firstWhere((e) => e.value == outAddress,
orElse: () => isar_models.Address(
walletId: walletId,
value: outAddress,
derivationIndex: -1,
derivationPath: null,
type: isar_models.AddressType.nonWallet,
subType: isar_models.AddressSubType.nonWallet,
publicKey: [],
));
List<isar_models.Output> outs = [];
List<isar_models.Input> ins = [];
for (final json in txObject["vin"] as List) {
bool isCoinBase = json['coinbase'] != null;
final input = isar_models.Input(
txid: json['txid'] as String? ?? "",
vout: json['vout'] as int? ?? -1,
scriptSig: json['scriptSig']?['hex'] as String?,
scriptSigAsm: json['scriptSig']?['asm'] as String?,
isCoinbase: isCoinBase ? isCoinBase : json['is_coinbase'] as bool?,
sequence: json['sequence'] as int?,
innerRedeemScriptAsm: json['innerRedeemscriptAsm'] as String?,
);
ins.add(input);
}
for (final json in txObject["vout"] as List) {
final output = isar_models.Output(
scriptPubKey: json['scriptPubKey']?['hex'] as String?,
scriptPubKeyAsm: json['scriptPubKey']?['asm'] as String?,
scriptPubKeyType: json['scriptPubKey']?['type'] as String?,
scriptPubKeyAddress:
json["scriptPubKey"]?["addresses"]?[0] as String? ??
json['scriptPubKey']['type'] as String,
value: Amount.fromDecimal(
Decimal.parse(json["value"].toString()),
fractionDigits: coin.decimals,
).raw.toInt(),
);
outs.add(output);
}
final tx = isar_models.Transaction(
walletId: walletId,
txid: txObject["txid"] as String,
timestamp: txObject["blocktime"] as int? ??
(DateTime.now().millisecondsSinceEpoch ~/ 1000),
type: type,
subType: subType,
amount: amount,
amountString: Amount(
rawValue: BigInt.from(amount),
fractionDigits: Coin.firo.decimals,
).toJsonString(),
fee: fees,
height: txObject["height"] as int? ?? 0,
isCancelled: false,
isLelantus: false,
slateId: null,
otherData: null,
nonce: null,
inputs: ins,
outputs: outs,
// if (storedTx == null ||
// !storedTx.isConfirmed(currentHeight, MINIMUM_CONFIRMATIONS)) {
final tx = await cachedElectrumXClient.getTransaction(
txHash: txHash["tx_hash"] as String,
verbose: true,
coin: coin,
);
txnsData.add(Tuple2(tx, transactionAddress));
if (!_duplicateTxCheck(allTransactions, tx["txid"] as String)) {
tx["address"] = await db
.getAddresses(walletId)
.filter()
.valueEqualTo(txHash["address"] as String)
.findFirst();
tx["height"] = txHash["height"];
allTransactions.add(tx);
}
// }
}
final List<Tuple2<isar_models.Transaction, isar_models.Address?>> txnsData =
[];
for (final txObject in allTransactions) {
final inputList = txObject["vin"] as List;
final outputList = txObject["vout"] as List;
bool isMint = false;
bool isJMint = false;
// check if tx is Mint or jMint
for (final output in outputList) {
if (output["scriptPubKey"]?["type"] == "lelantusmint") {
final asm = output["scriptPubKey"]?["asm"] as String?;
if (asm != null) {
if (asm.startsWith("OP_LELANTUSJMINT")) {
isJMint = true;
break;
} else if (asm.startsWith("OP_LELANTUSMINT")) {
isMint = true;
break;
} else {
Logging.instance.log(
"Unknown mint op code found for lelantusmint tx: ${txObject["txid"]}",
level: LogLevel.Error,
);
}
} else {
Logging.instance.log(
"ASM for lelantusmint tx: ${txObject["txid"]} is null!",
level: LogLevel.Error,
);
}
}
}
Set<String> inputAddresses = {};
Set<String> outputAddresses = {};
Amount totalInputValue = Amount(
rawValue: BigInt.zero,
fractionDigits: coin.decimals,
);
Amount totalOutputValue = Amount(
rawValue: BigInt.zero,
fractionDigits: coin.decimals,
);
Amount amountSentFromWallet = Amount(
rawValue: BigInt.zero,
fractionDigits: coin.decimals,
);
Amount amountReceivedInWallet = Amount(
rawValue: BigInt.zero,
fractionDigits: coin.decimals,
);
Amount changeAmount = Amount(
rawValue: BigInt.zero,
fractionDigits: coin.decimals,
);
// Parse mint transaction ================================================
// We should be able to assume this belongs to this wallet
if (isMint) {
List<isar_models.Input> ins = [];
// Parse inputs
for (final input in inputList) {
// Both value and address should not be null for a mint
final address = input["address"] as String?;
final value = input["valueSat"] as int?;
// We should not need to check whether the mint belongs to this
// wallet as any tx we look up will be looked up by one of this
// wallet's addresses
if (address != null && value != null) {
totalInputValue += value.toAmountAsRaw(
fractionDigits: coin.decimals,
);
}
ins.add(
isar_models.Input(
txid: input['txid'] as String? ?? "",
vout: input['vout'] as int? ?? -1,
scriptSig: input['scriptSig']?['hex'] as String?,
scriptSigAsm: input['scriptSig']?['asm'] as String?,
isCoinbase: input['is_coinbase'] as bool?,
sequence: input['sequence'] as int?,
innerRedeemScriptAsm: input['innerRedeemscriptAsm'] as String?,
),
);
}
// Parse outputs
for (final output in outputList) {
// get value
final value = Amount.fromDecimal(
Decimal.parse(output["value"].toString()),
fractionDigits: coin.decimals,
);
// add value to total
totalOutputValue += value;
}
final fee = totalInputValue - totalOutputValue;
final tx = isar_models.Transaction(
walletId: walletId,
txid: txObject["txid"] as String,
timestamp: txObject["blocktime"] as int? ??
(DateTime.now().millisecondsSinceEpoch ~/ 1000),
type: isar_models.TransactionType.sentToSelf,
subType: isar_models.TransactionSubType.mint,
amount: totalOutputValue.raw.toInt(),
amountString: totalOutputValue.toJsonString(),
fee: fee.raw.toInt(),
height: txObject["height"] as int?,
isCancelled: false,
isLelantus: true,
slateId: null,
otherData: null,
nonce: null,
inputs: ins,
outputs: [],
);
txnsData.add(Tuple2(tx, null));
// Otherwise parse JMint transaction ===================================
} else if (isJMint) {
Amount jMintFees = Amount(
rawValue: BigInt.zero,
fractionDigits: coin.decimals,
);
// Parse inputs
List<isar_models.Input> ins = [];
for (final input in inputList) {
// JMint fee
final nFee = Decimal.tryParse(input["nFees"].toString());
if (nFee != null) {
final fees = Amount.fromDecimal(
nFee,
fractionDigits: coin.decimals,
);
jMintFees += fees;
}
ins.add(
isar_models.Input(
txid: input['txid'] as String? ?? "",
vout: input['vout'] as int? ?? -1,
scriptSig: input['scriptSig']?['hex'] as String?,
scriptSigAsm: input['scriptSig']?['asm'] as String?,
isCoinbase: input['is_coinbase'] as bool?,
sequence: input['sequence'] as int?,
innerRedeemScriptAsm: input['innerRedeemscriptAsm'] as String?,
),
);
}
bool nonWalletAddressFoundInOutputs = false;
// Parse outputs
List<isar_models.Output> outs = [];
for (final output in outputList) {
// get value
final value = Amount.fromDecimal(
Decimal.parse(output["value"].toString()),
fractionDigits: coin.decimals,
);
// add value to total
totalOutputValue += value;
final address = output["scriptPubKey"]?["addresses"]?[0] as String? ??
output['scriptPubKey']?['address'] as String?;
if (address != null) {
outputAddresses.add(address);
if (receivingAddresses.contains(address) ||
changeAddresses.contains(address)) {
amountReceivedInWallet += value;
} else {
nonWalletAddressFoundInOutputs = true;
}
}
outs.add(
isar_models.Output(
scriptPubKey: output['scriptPubKey']?['hex'] as String?,
scriptPubKeyAsm: output['scriptPubKey']?['asm'] as String?,
scriptPubKeyType: output['scriptPubKey']?['type'] as String?,
scriptPubKeyAddress: address ?? "jmint",
value: value.raw.toInt(),
),
);
}
const subType = isar_models.TransactionSubType.join;
final type = nonWalletAddressFoundInOutputs
? isar_models.TransactionType.outgoing
: isar_models.TransactionType.incoming;
final amount = nonWalletAddressFoundInOutputs
? totalOutputValue
: amountReceivedInWallet;
final possibleNonWalletAddresses =
receivingAddresses.difference(outputAddresses);
final possibleReceivingAddresses =
receivingAddresses.intersection(outputAddresses);
final transactionAddress = nonWalletAddressFoundInOutputs
? isar_models.Address(
walletId: walletId,
value: possibleNonWalletAddresses.first,
derivationIndex: -1,
derivationPath: null,
type: isar_models.AddressType.nonWallet,
subType: isar_models.AddressSubType.nonWallet,
publicKey: [],
)
: allAddresses.firstWhere(
(e) => e.value == possibleReceivingAddresses.first,
);
final tx = isar_models.Transaction(
walletId: walletId,
txid: txObject["txid"] as String,
timestamp: txObject["blocktime"] as int? ??
(DateTime.now().millisecondsSinceEpoch ~/ 1000),
type: type,
subType: subType,
amount: amount.raw.toInt(),
amountString: amount.toJsonString(),
fee: jMintFees.raw.toInt(),
height: txObject["height"] as int?,
isCancelled: false,
isLelantus: true,
slateId: null,
otherData: null,
nonce: null,
inputs: ins,
outputs: outs,
);
txnsData.add(Tuple2(tx, transactionAddress));
// Assume non lelantus transaction =====================================
} else {
// parse inputs
List<isar_models.Input> ins = [];
for (final input in inputList) {
final valueSat = input["valueSat"] as int?;
final address = input["address"] as String? ??
input["scriptPubKey"]?["address"] as String? ??
input["scriptPubKey"]?["addresses"]?[0] as String?;
if (address != null && valueSat != null) {
final value = valueSat.toAmountAsRaw(
fractionDigits: coin.decimals,
);
// add value to total
totalInputValue += value;
inputAddresses.add(address);
// if input was from my wallet, add value to amount sent
if (receivingAddresses.contains(address) ||
changeAddresses.contains(address)) {
amountSentFromWallet += value;
}
}
ins.add(
isar_models.Input(
txid: input['txid'] as String,
vout: input['vout'] as int? ?? -1,
scriptSig: input['scriptSig']?['hex'] as String?,
scriptSigAsm: input['scriptSig']?['asm'] as String?,
isCoinbase: input['is_coinbase'] as bool?,
sequence: input['sequence'] as int?,
innerRedeemScriptAsm: input['innerRedeemscriptAsm'] as String?,
),
);
}
// parse outputs
List<isar_models.Output> outs = [];
for (final output in outputList) {
// get value
final value = Amount.fromDecimal(
Decimal.parse(output["value"].toString()),
fractionDigits: coin.decimals,
);
// add value to total
totalOutputValue += value;
// get output address
final address = output["scriptPubKey"]?["addresses"]?[0] as String? ??
output["scriptPubKey"]?["address"] as String?;
if (address != null) {
outputAddresses.add(address);
// if output was to my wallet, add value to amount received
if (receivingAddresses.contains(address)) {
amountReceivedInWallet += value;
} else if (changeAddresses.contains(address)) {
changeAmount += value;
}
}
outs.add(
isar_models.Output(
scriptPubKey: output['scriptPubKey']?['hex'] as String?,
scriptPubKeyAsm: output['scriptPubKey']?['asm'] as String?,
scriptPubKeyType: output['scriptPubKey']?['type'] as String?,
scriptPubKeyAddress: address ?? "",
value: value.raw.toInt(),
),
);
}
final mySentFromAddresses = [
...receivingAddresses.intersection(inputAddresses),
...changeAddresses.intersection(inputAddresses)
];
final myReceivedOnAddresses =
receivingAddresses.intersection(outputAddresses);
final myChangeReceivedOnAddresses =
changeAddresses.intersection(outputAddresses);
final fee = totalInputValue - totalOutputValue;
// this is the address initially used to fetch the txid
isar_models.Address transactionAddress =
txObject["address"] as isar_models.Address;
isar_models.TransactionType type;
Amount amount;
if (mySentFromAddresses.isNotEmpty &&
myReceivedOnAddresses.isNotEmpty) {
// tx is sent to self
type = isar_models.TransactionType.sentToSelf;
// should be 0
amount = amountSentFromWallet -
amountReceivedInWallet -
fee -
changeAmount;
} else if (mySentFromAddresses.isNotEmpty) {
// outgoing tx
type = isar_models.TransactionType.outgoing;
amount = amountSentFromWallet - changeAmount - fee;
final possible =
outputAddresses.difference(myChangeReceivedOnAddresses).first;
if (transactionAddress.value != possible) {
transactionAddress = isar_models.Address(
walletId: walletId,
value: possible,
derivationIndex: -1,
derivationPath: null,
subType: isar_models.AddressSubType.nonWallet,
type: isar_models.AddressType.nonWallet,
publicKey: [],
);
}
} else {
// incoming tx
type = isar_models.TransactionType.incoming;
amount = amountReceivedInWallet;
}
final tx = isar_models.Transaction(
walletId: walletId,
txid: txObject["txid"] as String,
timestamp: txObject["blocktime"] as int? ??
(DateTime.now().millisecondsSinceEpoch ~/ 1000),
type: type,
subType: isar_models.TransactionSubType.none,
// amount may overflow. Deprecated. Use amountString
amount: amount.raw.toInt(),
amountString: amount.toJsonString(),
fee: fee.raw.toInt(),
height: txObject["height"] as int?,
isCancelled: false,
isLelantus: false,
slateId: null,
otherData: null,
nonce: null,
inputs: ins,
outputs: outs,
);
txnsData.add(Tuple2(tx, transactionAddress));
}
}
await db.addNewTransactionData(txnsData, walletId);
@ -4568,8 +4701,6 @@ class FiroWallet extends CoinServiceAPI
final response = await cachedElectrumXClient.getUsedCoinSerials(
coin: coin,
);
print("getUsedCoinSerials");
print(response);
return response;
} catch (e, s) {
Logging.instance.log("Exception rethrown in firo_wallet.dart: $e\n$s",

View file

@ -1791,6 +1791,18 @@ class LitecoinWallet extends CoinServiceAPI
coin: coin,
);
bool shouldBlock = false;
String? blockReason;
String? label;
final utxoAmount = jsonUTXO["value"] as int;
if (utxoAmount <= 10000) {
shouldBlock = true;
blockReason = "May contain ordinal";
label = "Possible ordinal";
}
final vout = jsonUTXO["tx_pos"] as int;
final outputs = txn["vout"] as List;
@ -1809,10 +1821,10 @@ class LitecoinWallet extends CoinServiceAPI
walletId: walletId,
txid: txn["txid"] as String,
vout: vout,
value: jsonUTXO["value"] as int,
name: "",
isBlocked: false,
blockedReason: null,
value: utxoAmount,
name: label ?? "",
isBlocked: shouldBlock,
blockedReason: blockReason,
isCoinbase: txn["is_coinbase"] as bool? ?? false,
blockHash: txn["blockhash"] as String?,
blockHeight: jsonUTXO["height"] as int?,
@ -1824,16 +1836,20 @@ class LitecoinWallet extends CoinServiceAPI
}
}
Logging.instance
.log('Outputs fetched: $outputArray', level: LogLevel.Info);
Logging.instance.log(
'Outputs fetched: $outputArray',
level: LogLevel.Info,
);
await db.updateUTXOs(walletId, outputArray);
// finally update balance
await _updateBalance();
} catch (e, s) {
Logging.instance
.log("Output fetch unsuccessful: $e\n$s", level: LogLevel.Error);
Logging.instance.log(
"Output fetch unsuccessful: $e\n$s",
level: LogLevel.Error,
);
}
}

View file

@ -148,19 +148,31 @@ mixin ElectrumXParsing {
type = TransactionType.outgoing;
amount = amountSentFromWallet - changeAmount - fee;
final possible =
outputAddresses.difference(myChangeReceivedOnAddresses).first;
// non wallet addresses found in tx outputs
final nonWalletOutAddresses = outputAddresses.difference(
myChangeReceivedOnAddresses,
);
if (transactionAddress.value != possible) {
transactionAddress = Address(
walletId: walletId,
value: possible,
derivationIndex: -1,
derivationPath: null,
subType: AddressSubType.nonWallet,
type: AddressType.nonWallet,
publicKey: [],
);
if (nonWalletOutAddresses.isNotEmpty) {
final possible = nonWalletOutAddresses.first;
if (transactionAddress.value != possible) {
transactionAddress = Address(
walletId: walletId,
value: possible,
derivationIndex: -1,
derivationPath: null,
subType: AddressSubType.nonWallet,
type: AddressType.nonWallet,
publicKey: [],
);
}
} else {
// some other type of tx where the receiving address is
// one of my change addresses
type = TransactionType.sentToSelf;
amount = changeAmount;
}
} else {
// incoming tx

View file

@ -35,9 +35,10 @@ mixin FiroHive {
}
// mintIndex
int? firoGetMintIndex() {
int firoGetMintIndex() {
return DB.instance.get<dynamic>(boxName: _walletId, key: "mintIndex")
as int?;
as int? ??
0;
}
Future<void> firoUpdateMintIndex(int mintIndex) async {

View file

@ -62,7 +62,7 @@ extension CoinExt on Coin {
case Coin.epicCash:
return "Epic Cash";
case Coin.eCash:
return "E-Cash";
return "eCash";
case Coin.ethereum:
return "Ethereum";
case Coin.firo:

View file

@ -1,3 +1,5 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
@ -50,4 +52,15 @@ abstract class Util {
}
return MaterialColor(color.value, swatch);
}
static void printJson(dynamic json) {
if (json is Map || json is List) {
final spaces = ' ' * 4;
final encoder = JsonEncoder.withIndent(spaces);
final pretty = encoder.convert(json);
log(pretty);
} else {
log(dynamic.toString());
}
}
}

View file

@ -4,8 +4,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/svg.dart';
import 'package:stackwallet/providers/providers.dart';
import 'package:stackwallet/services/coins/firo/firo_wallet.dart';
import 'package:stackwallet/themes/coin_icon_provider.dart';
import 'package:stackwallet/themes/stack_colors.dart';
import 'package:stackwallet/utilities/amount/amount.dart';
import 'package:stackwallet/utilities/constants.dart';
import 'package:stackwallet/utilities/enums/coin_enum.dart';
import 'package:stackwallet/utilities/text_styles.dart';
@ -34,6 +36,28 @@ class _ManagedFavoriteCardState extends ConsumerState<ManagedFavorite> {
final isDesktop = Util.isDesktop;
final balance = ref.watch(
walletsChangeNotifierProvider.select(
(value) => value.getManager(widget.walletId).balance,
),
);
Amount total = balance.total;
if (manager.coin == Coin.firo || manager.coin == Coin.firoTestNet) {
final balancePrivate = ref.watch(
walletsChangeNotifierProvider.select(
(value) => (value
.getManager(
widget.walletId,
)
.wallet as FiroWallet)
.balancePrivate,
),
);
total += balancePrivate.total;
}
return RoundedWhiteContainer(
padding: EdgeInsets.all(isDesktop ? 0 : 4.0),
child: RawMaterialButton(
@ -107,7 +131,7 @@ class _ManagedFavoriteCardState extends ConsumerState<ManagedFavorite> {
),
Expanded(
child: Text(
"${manager.balance.total.localizedStringAsFixed(
"${total.localizedStringAsFixed(
locale: ref.watch(
localeServiceChangeNotifierProvider.select(
(value) => value.locale,
@ -150,7 +174,7 @@ class _ManagedFavoriteCardState extends ConsumerState<ManagedFavorite> {
height: 2,
),
Text(
"${manager.balance.total.localizedStringAsFixed(
"${total.localizedStringAsFixed(
locale: ref.watch(
localeServiceChangeNotifierProvider.select(
(value) => value.locale,

View file

@ -11,7 +11,7 @@ description: Stack Wallet
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.7.9+173
version: 1.7.10+174
environment:
sdk: ">=2.17.0 <3.0.0"