spark spend from transparent and various clean up

This commit is contained in:
julian 2023-12-27 10:01:13 -06:00
parent cb46c2fa3a
commit 953acb493c
7 changed files with 673 additions and 401 deletions

View file

@ -10,15 +10,18 @@
import 'dart:async';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:isar/isar.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:stackwallet/models/isar/models/isar_models.dart';
import 'package:stackwallet/notifications/show_flush_bar.dart';
import 'package:stackwallet/pages/receive_view/addresses/wallet_addresses_view.dart';
import 'package:stackwallet/pages/receive_view/generate_receiving_uri_qr_code_view.dart';
import 'package:stackwallet/providers/db/main_db_provider.dart';
import 'package:stackwallet/providers/providers.dart';
import 'package:stackwallet/route_generator.dart';
import 'package:stackwallet/themes/stack_colors.dart';
@ -30,7 +33,9 @@ import 'package:stackwallet/utilities/enums/coin_enum.dart';
import 'package:stackwallet/utilities/text_styles.dart';
import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart';
import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/multi_address_interface.dart';
import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart';
import 'package:stackwallet/widgets/background.dart';
import 'package:stackwallet/widgets/conditional_parent.dart';
import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart';
import 'package:stackwallet/widgets/custom_buttons/blue_text_button.dart';
import 'package:stackwallet/widgets/custom_loading_overlay.dart';
@ -58,6 +63,11 @@ class _ReceiveViewState extends ConsumerState<ReceiveView> {
late final Coin coin;
late final String walletId;
late final ClipboardInterface clipboard;
late final bool supportsSpark;
String? _sparkAddress;
String? _qrcodeContent;
bool _showSparkAddress = true;
Future<void> generateNewAddress() async {
final wallet = ref.read(pWallets).getWallet(walletId);
@ -96,23 +106,106 @@ class _ReceiveViewState extends ConsumerState<ReceiveView> {
}
}
Future<void> generateNewSparkAddress() async {
final wallet = ref.read(pWallets).getWallet(walletId);
if (wallet is SparkInterface) {
bool shouldPop = false;
unawaited(
showDialog(
context: context,
builder: (_) {
return WillPopScope(
onWillPop: () async => shouldPop,
child: Container(
color: Theme.of(context)
.extension<StackColors>()!
.overlay
.withOpacity(0.5),
child: const CustomLoadingOverlay(
message: "Generating address",
eventBus: null,
),
),
);
},
),
);
final address = await wallet.generateNextSparkAddress();
await ref.read(mainDBProvider).isar.writeTxn(() async {
await ref.read(mainDBProvider).isar.addresses.put(address);
});
shouldPop = true;
if (mounted) {
Navigator.of(context, rootNavigator: true).pop();
if (_sparkAddress != address.value) {
setState(() {
_sparkAddress = address.value;
});
}
}
}
}
StreamSubscription<Address?>? _streamSub;
@override
void initState() {
walletId = widget.walletId;
coin = ref.read(pWalletCoin(walletId));
clipboard = widget.clipboard;
supportsSpark = ref.read(pWallets).getWallet(walletId) is SparkInterface;
if (supportsSpark) {
_streamSub = ref
.read(mainDBProvider)
.isar
.addresses
.where()
.walletIdEqualTo(walletId)
.filter()
.typeEqualTo(AddressType.spark)
.sortByDerivationIndexDesc()
.findFirst()
.asStream()
.listen((event) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_sparkAddress = event?.value;
});
}
});
});
}
super.initState();
}
@override
void dispose() {
_streamSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
debugPrint("BUILD: $runtimeType");
final receivingAddress = ref.watch(pWalletReceivingAddress(walletId));
final ticker = widget.tokenContract?.symbol ?? coin.ticker;
if (supportsSpark) {
if (_showSparkAddress) {
_qrcodeContent = _sparkAddress;
} else {
_qrcodeContent = ref.watch(pWalletReceivingAddress(walletId));
}
} else {
_qrcodeContent = ref.watch(pWalletReceivingAddress(walletId));
}
return Background(
child: Scaffold(
backgroundColor: Theme.of(context).extension<StackColors>()!.background,
@ -225,86 +318,239 @@ class _ReceiveViewState extends ConsumerState<ReceiveView> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
GestureDetector(
onTap: () {
HapticFeedback.lightImpact();
clipboard.setData(
ClipboardData(text: receivingAddress),
);
showFloatingFlushBar(
type: FlushBarType.info,
message: "Copied to clipboard",
iconAsset: Assets.svg.copy,
context: context,
);
},
child: RoundedWhiteContainer(
child: Column(
children: [
Row(
children: [
Text(
"Your $ticker address",
style: STextStyles.itemSubtitle(context),
),
const Spacer(),
Row(
children: [
SvgPicture.asset(
Assets.svg.copy,
width: 10,
height: 10,
color: Theme.of(context)
.extension<StackColors>()!
.infoItemIcons,
),
const SizedBox(
width: 4,
),
Text(
"Copy",
style: STextStyles.link2(context),
),
],
),
],
),
const SizedBox(
height: 4,
),
Row(
children: [
Expanded(
ConditionalParent(
condition: supportsSpark,
builder: (child) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonHideUnderline(
child: DropdownButton2<bool>(
value: _showSparkAddress,
items: [
DropdownMenuItem(
value: true,
child: Text(
receivingAddress,
style: STextStyles.itemSubtitle12(context),
"Spark address",
style: STextStyles.desktopTextMedium(context),
),
),
DropdownMenuItem(
value: false,
child: Text(
"Transparent address",
style: STextStyles.desktopTextMedium(context),
),
),
],
onChanged: (value) {
if (value is bool && value != _showSparkAddress) {
setState(() {
_showSparkAddress = value;
});
}
},
isExpanded: true,
iconStyleData: IconStyleData(
icon: Padding(
padding: const EdgeInsets.only(right: 10),
child: SvgPicture.asset(
Assets.svg.chevronDown,
width: 12,
height: 6,
color: Theme.of(context)
.extension<StackColors>()!
.textFieldActiveSearchIconRight,
),
),
),
dropdownStyleData: DropdownStyleData(
offset: const Offset(0, -10),
elevation: 0,
decoration: BoxDecoration(
color: Theme.of(context)
.extension<StackColors>()!
.textFieldDefaultBG,
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
),
),
),
menuItemStyleData: const MenuItemStyleData(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
),
],
),
const SizedBox(
height: 12,
),
if (_showSparkAddress)
GestureDetector(
onTap: () {
clipboard.setData(
ClipboardData(text: _sparkAddress ?? "Error"),
);
showFloatingFlushBar(
type: FlushBarType.info,
message: "Copied to clipboard",
iconAsset: Assets.svg.copy,
context: context,
);
},
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context)
.extension<StackColors>()!
.backgroundAppBar,
width: 1,
),
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
),
),
child: RoundedWhiteContainer(
child: Column(
children: [
Row(
children: [
Text(
"Your ${coin.ticker} SPARK address",
style:
STextStyles.itemSubtitle(context),
),
const Spacer(),
Row(
children: [
SvgPicture.asset(
Assets.svg.copy,
width: 15,
height: 15,
color: Theme.of(context)
.extension<StackColors>()!
.infoItemIcons,
),
const SizedBox(
width: 4,
),
Text(
"Copy",
style: STextStyles.link2(context),
),
],
),
],
),
const SizedBox(
height: 8,
),
Row(
children: [
Expanded(
child: Text(
_sparkAddress ?? "Error",
style: STextStyles
.desktopTextExtraExtraSmall(
context)
.copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.textDark,
),
),
),
],
),
],
),
),
),
),
if (!_showSparkAddress) child,
],
),
child: GestureDetector(
onTap: () {
HapticFeedback.lightImpact();
clipboard.setData(
ClipboardData(
text:
ref.watch(pWalletReceivingAddress(walletId))),
);
showFloatingFlushBar(
type: FlushBarType.info,
message: "Copied to clipboard",
iconAsset: Assets.svg.copy,
context: context,
);
},
child: RoundedWhiteContainer(
child: Column(
children: [
Row(
children: [
Text(
"Your $ticker address",
style: STextStyles.itemSubtitle(context),
),
const Spacer(),
Row(
children: [
SvgPicture.asset(
Assets.svg.copy,
width: 10,
height: 10,
color: Theme.of(context)
.extension<StackColors>()!
.infoItemIcons,
),
const SizedBox(
width: 4,
),
Text(
"Copy",
style: STextStyles.link2(context),
),
],
),
],
),
const SizedBox(
height: 4,
),
Row(
children: [
Expanded(
child: Text(
ref.watch(
pWalletReceivingAddress(walletId)),
style: STextStyles.itemSubtitle12(context),
),
),
],
),
],
),
),
),
),
if (coin != Coin.epicCash &&
coin != Coin.ethereum &&
coin != Coin.banano &&
coin != Coin.nano &&
coin != Coin.stellar &&
coin != Coin.stellarTestnet &&
coin != Coin.tezos)
if (ref.watch(pWallets
.select((value) => value.getWallet(walletId)))
is MultiAddressInterface ||
supportsSpark)
const SizedBox(
height: 12,
),
if (coin != Coin.epicCash &&
coin != Coin.ethereum &&
coin != Coin.banano &&
coin != Coin.nano &&
coin != Coin.stellar &&
coin != Coin.stellarTestnet &&
coin != Coin.tezos)
if (ref.watch(pWallets
.select((value) => value.getWallet(walletId)))
is MultiAddressInterface ||
supportsSpark)
TextButton(
onPressed: generateNewAddress,
onPressed: supportsSpark && _showSparkAddress
? generateNewSparkAddress
: generateNewAddress,
style: Theme.of(context)
.extension<StackColors>()!
.getSecondaryEnabledButtonStyle(context),
@ -328,7 +574,7 @@ class _ReceiveViewState extends ConsumerState<ReceiveView> {
QrImageView(
data: AddressUtils.buildUriString(
coin,
receivingAddress,
_qrcodeContent ?? "",
{},
),
size: MediaQuery.of(context).size.width / 2,
@ -347,7 +593,7 @@ class _ReceiveViewState extends ConsumerState<ReceiveView> {
RouteGenerator.useMaterialPageRoute,
builder: (_) => GenerateUriQrCodeView(
coin: coin,
receivingAddress: receivingAddress,
receivingAddress: _qrcodeContent ?? "",
),
settings: const RouteSettings(
name: GenerateUriQrCodeView.routeName,

View file

@ -120,7 +120,7 @@ class _ConfirmTransactionViewState
),
);
late String txid;
final List<String> txids = [];
Future<TxData> txDataFuture;
final note = noteController.text;
@ -143,7 +143,12 @@ class _ConfirmTransactionViewState
if (wallet is FiroWallet) {
switch (ref.read(publicPrivateBalanceStateProvider.state).state) {
case FiroType.public:
txDataFuture = wallet.confirmSend(txData: widget.txData);
if (widget.txData.sparkMints == null) {
txDataFuture = wallet.confirmSend(txData: widget.txData);
} else {
txDataFuture =
wallet.confirmSparkMintTransactions(txData: widget.txData);
}
break;
case FiroType.lelantus:
@ -175,17 +180,24 @@ class _ConfirmTransactionViewState
sendProgressController.triggerSuccess?.call();
await Future<void>.delayed(const Duration(seconds: 5));
txid = (results.first as TxData).txid!;
if (wallet is FiroWallet &&
(results.first as TxData).sparkMints != null) {
txids.addAll((results.first as TxData).sparkMints!.map((e) => e.txid!));
} else {
txids.add((results.first as TxData).txid!);
}
ref.refresh(desktopUseUTXOs);
// save note
await ref.read(mainDBProvider).putTransactionNote(
TransactionNote(
walletId: walletId,
txid: txid,
value: note,
),
);
for (final txid in txids) {
await ref.read(mainDBProvider).putTransactionNote(
TransactionNote(
walletId: walletId,
txid: txid,
value: note,
),
);
}
if (widget.isTokenTx) {
unawaited(ref.read(tokenServiceProvider)!.refresh());
@ -333,6 +345,48 @@ class _ConfirmTransactionViewState
} else {
unit = coin.ticker;
}
final Amount? fee;
final Amount amount;
final wallet = ref.watch(pWallets).getWallet(walletId);
if (wallet is FiroWallet) {
switch (ref.read(publicPrivateBalanceStateProvider.state).state) {
case FiroType.public:
if (widget.txData.sparkMints != null) {
fee = widget.txData.sparkMints!
.map((e) => e.fee!)
.reduce((value, element) => value += element);
amount = widget.txData.sparkMints!
.map((e) => e.amountSpark!)
.reduce((value, element) => value += element);
} else {
fee = widget.txData.fee;
amount = widget.txData.amount!;
}
break;
case FiroType.lelantus:
fee = widget.txData.fee;
amount = widget.txData.amount!;
break;
case FiroType.spark:
fee = widget.txData.fee;
amount = (widget.txData.amount ??
Amount.zeroWith(
fractionDigits: wallet.cryptoCurrency.fractionDigits)) +
(widget.txData.amountSpark ??
Amount.zeroWith(
fractionDigits: wallet.cryptoCurrency.fractionDigits));
break;
}
} else {
fee = widget.txData.fee;
amount = widget.txData.amount!;
}
return ConditionalParent(
condition: !isDesktop,
builder: (child) => Background(
@ -438,7 +492,8 @@ class _ConfirmTransactionViewState
Text(
widget.isPaynymTransaction
? widget.txData.paynymAccountLite!.nymName
: widget.txData.recipients!.first.address,
: widget.txData.recipients?.first.address ??
widget.txData.sparkRecipients!.first.address,
style: STextStyles.itemSubtitle12(context),
),
],
@ -457,7 +512,7 @@ class _ConfirmTransactionViewState
),
SelectableText(
ref.watch(pAmountFormatter(coin)).format(
widget.txData.amount!,
amount,
ethContract: ref
.watch(tokenServiceProvider)
?.tokenContract,
@ -482,9 +537,7 @@ class _ConfirmTransactionViewState
style: STextStyles.smallMed12(context),
),
SelectableText(
ref
.watch(pAmountFormatter(coin))
.format(widget.txData.fee!),
ref.watch(pAmountFormatter(coin)).format(fee!),
style: STextStyles.itemSubtitle12(context),
textAlign: TextAlign.right,
),
@ -508,7 +561,7 @@ class _ConfirmTransactionViewState
height: 4,
),
SelectableText(
"~${widget.txData.fee!.raw.toInt() ~/ widget.txData.vSize!}",
"~${fee!.raw.toInt() ~/ widget.txData.vSize!}",
style: STextStyles.itemSubtitle12(context),
),
],
@ -639,9 +692,6 @@ class _ConfirmTransactionViewState
),
Builder(
builder: (context) {
// TODO: [prio=high] spark transaction specifics - better handling
final amount = widget.txData.amount ??
widget.txData.amountSpark!;
final externalCalls = ref.watch(
prefsChangeNotifierProvider.select(
(value) => value.externalCalls));
@ -778,24 +828,15 @@ class _ConfirmTransactionViewState
const SizedBox(
height: 2,
),
Builder(
builder: (context) {
final fee = widget.txData.fee!;
return SelectableText(
ref
.watch(pAmountFormatter(coin))
.format(fee),
style:
STextStyles.desktopTextExtraExtraSmall(
context)
.copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.textDark,
),
);
},
SelectableText(
ref.watch(pAmountFormatter(coin)).format(fee!),
style: STextStyles.desktopTextExtraExtraSmall(
context)
.copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.textDark,
),
),
],
),
@ -1000,15 +1041,9 @@ class _ConfirmTransactionViewState
color: Theme.of(context)
.extension<StackColors>()!
.textFieldDefaultBG,
child: Builder(
builder: (context) {
final fee = widget.txData.fee!;
return SelectableText(
ref.watch(pAmountFormatter(coin)).format(fee),
style: STextStyles.itemSubtitle(context),
);
},
child: SelectableText(
ref.watch(pAmountFormatter(coin)).format(fee!),
style: STextStyles.itemSubtitle(context),
),
),
),
@ -1044,7 +1079,7 @@ class _ConfirmTransactionViewState
.extension<StackColors>()!
.textFieldDefaultBG,
child: SelectableText(
"~${widget.txData.fee!.raw.toInt() ~/ widget.txData.vSize!}",
"~${fee!.raw.toInt() ~/ widget.txData.vSize!}",
style: STextStyles.itemSubtitle(context),
),
),
@ -1088,31 +1123,22 @@ class _ConfirmTransactionViewState
.textConfirmTotalAmount,
),
),
Builder(builder: (context) {
final fee = widget.txData.fee!;
// TODO: [prio=high] spark transaction specifics - better handling
final amount =
widget.txData.amount ?? widget.txData.amountSpark!;
return SelectableText(
ref
.watch(pAmountFormatter(coin))
.format(amount + fee),
style: isDesktop
? STextStyles.desktopTextExtraExtraSmall(context)
.copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.textConfirmTotalAmount,
)
: STextStyles.itemSubtitle12(context).copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.textConfirmTotalAmount,
),
textAlign: TextAlign.right,
);
}),
SelectableText(
ref.watch(pAmountFormatter(coin)).format(amount + fee!),
style: isDesktop
? STextStyles.desktopTextExtraExtraSmall(context)
.copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.textConfirmTotalAmount,
)
: STextStyles.itemSubtitle12(context).copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.textConfirmTotalAmount,
),
textAlign: TextAlign.right,
),
],
),
),

View file

@ -524,29 +524,39 @@ class _SendViewState extends ConsumerState<SendView> {
} else if (wallet is FiroWallet) {
switch (ref.read(publicPrivateBalanceStateProvider.state).state) {
case FiroType.public:
txDataFuture = wallet.prepareSend(
txData: TxData(
recipients: _isSparkAddress
? null
: [(address: _address!, amount: amount)],
sparkRecipients: _isSparkAddress
? [
(
address: _address!,
amount: amount,
memo: memoController.text,
)
]
: null,
feeRateType: ref.read(feeRateTypeStateProvider),
satsPerVByte: isCustomFee ? customFeeRate : null,
utxos: (wallet is CoinControlInterface &&
coinControlEnabled &&
selectedUTXOs.isNotEmpty)
? selectedUTXOs
: null,
),
);
if (_isSparkAddress) {
txDataFuture = wallet.prepareSparkMintTransaction(
txData: TxData(
sparkRecipients: [
(
address: _address!,
amount: amount,
memo: memoController.text,
)
],
feeRateType: ref.read(feeRateTypeStateProvider),
satsPerVByte: isCustomFee ? customFeeRate : null,
utxos: (wallet is CoinControlInterface &&
coinControlEnabled &&
selectedUTXOs.isNotEmpty)
? selectedUTXOs
: null,
),
);
} else {
txDataFuture = wallet.prepareSend(
txData: TxData(
recipients: [(address: _address!, amount: amount)],
feeRateType: ref.read(feeRateTypeStateProvider),
satsPerVByte: isCustomFee ? customFeeRate : null,
utxos: (wallet is CoinControlInterface &&
coinControlEnabled &&
selectedUTXOs.isNotEmpty)
? selectedUTXOs
: null,
),
);
}
break;
case FiroType.lelantus:

View file

@ -68,6 +68,7 @@ import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart';
import 'package:stackwallet/wallets/wallet/impl/firo_wallet.dart';
import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/cash_fusion_interface.dart';
import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart';
import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart';
import 'package:stackwallet/widgets/background.dart';
import 'package:stackwallet/widgets/conditional_parent.dart';
import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart';
@ -115,6 +116,8 @@ class _WalletViewState extends ConsumerState<WalletView> {
late final String walletId;
late final Coin coin;
late final bool isSparkWallet;
late final bool _shouldDisableAutoSyncOnLogOut;
late WalletSyncStatus _currentSyncStatus;
@ -171,6 +174,8 @@ class _WalletViewState extends ConsumerState<WalletView> {
_shouldDisableAutoSyncOnLogOut = false;
}
isSparkWallet = wallet is SparkInterface;
if (coin == Coin.firo &&
(wallet as FiroWallet).lelantusCoinIsarRescanRequired) {
_rescanningOnOpen = true;
@ -758,11 +763,11 @@ class _WalletViewState extends ConsumerState<WalletView> {
),
),
),
if (coin == Coin.firo)
if (isSparkWallet)
const SizedBox(
height: 10,
),
if (coin == Coin.firo)
if (isSparkWallet)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(

View file

@ -321,29 +321,39 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
} else if (wallet is FiroWallet) {
switch (ref.read(publicPrivateBalanceStateProvider.state).state) {
case FiroType.public:
txDataFuture = wallet.prepareSend(
txData: TxData(
recipients: _isSparkAddress
? null
: [(address: _address!, amount: amount)],
sparkRecipients: _isSparkAddress
? [
(
address: _address!,
amount: amount,
memo: memoController.text,
)
]
: null,
feeRateType: ref.read(feeRateTypeStateProvider),
satsPerVByte: isCustomFee ? customFeeRate : null,
utxos: (wallet is CoinControlInterface &&
coinControlEnabled &&
ref.read(desktopUseUTXOs).isNotEmpty)
? ref.read(desktopUseUTXOs)
: null,
),
);
if (_isSparkAddress) {
txDataFuture = wallet.prepareSparkMintTransaction(
txData: TxData(
sparkRecipients: [
(
address: _address!,
amount: amount,
memo: memoController.text,
)
],
feeRateType: ref.read(feeRateTypeStateProvider),
satsPerVByte: isCustomFee ? customFeeRate : null,
utxos: (wallet is CoinControlInterface &&
coinControlEnabled &&
ref.read(desktopUseUTXOs).isNotEmpty)
? ref.read(desktopUseUTXOs)
: null,
),
);
} else {
txDataFuture = wallet.prepareSend(
txData: TxData(
recipients: [(address: _address!, amount: amount)],
feeRateType: ref.read(feeRateTypeStateProvider),
satsPerVByte: isCustomFee ? customFeeRate : null,
utxos: (wallet is CoinControlInterface &&
coinControlEnabled &&
ref.read(desktopUseUTXOs).isNotEmpty)
? ref.read(desktopUseUTXOs)
: null,
),
);
}
break;
case FiroType.lelantus:
@ -579,7 +589,9 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
ref.read(pWallets).getWallet(walletId).cryptoCurrency;
final isValidAddress = walletCurrency.validateAddress(address ?? "");
_isSparkAddress = isValidAddress
_isSparkAddress = isValidAddress &&
ref.read(publicPrivateBalanceStateProvider.state).state !=
FiroType.lelantus
? SparkInterface.validateSparkAddress(
address: address!,
isTestNet: walletCurrency.network == CryptoCurrencyNetwork.test,
@ -1409,11 +1421,17 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
}
},
),
if (isStellar || _isSparkAddress)
if (isStellar ||
(_isSparkAddress &&
ref.watch(publicPrivateBalanceStateProvider) !=
FiroType.public))
const SizedBox(
height: 10,
),
if (isStellar || _isSparkAddress)
if (isStellar ||
(_isSparkAddress &&
ref.watch(publicPrivateBalanceStateProvider) !=
FiroType.public))
ClipRRect(
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,

View file

@ -62,6 +62,7 @@ class TxData {
Amount amount,
String memo,
})>? sparkRecipients;
final List<TxData>? sparkMints;
TxData({
this.feeRateType,
@ -94,6 +95,7 @@ class TxData {
this.mintsMapLelantus,
this.tezosOperationsList,
this.sparkRecipients,
this.sparkMints,
});
Amount? get amount => recipients != null && recipients!.isNotEmpty
@ -150,6 +152,7 @@ class TxData {
String memo,
})>?
sparkRecipients,
List<TxData>? sparkMints,
}) {
return TxData(
feeRateType: feeRateType ?? this.feeRateType,
@ -183,6 +186,7 @@ class TxData {
mintsMapLelantus: mintsMapLelantus ?? this.mintsMapLelantus,
tezosOperationsList: tezosOperationsList ?? this.tezosOperationsList,
sparkRecipients: sparkRecipients ?? this.sparkRecipients,
sparkMints: sparkMints ?? this.sparkMints,
);
}
@ -218,5 +222,6 @@ class TxData {
'mintsMapLelantus: $mintsMapLelantus, '
'tezosOperationsList: $tezosOperationsList, '
'sparkRecipients: $sparkRecipients, '
'sparkMints: $sparkMints, '
'}';
}

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:math';
import 'package:bitcoindart/bitcoindart.dart' as btc;
import 'package:decimal/decimal.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_libsparkmobile/flutter_libsparkmobile.dart';
import 'package:isar/isar.dart';
@ -19,8 +20,12 @@ import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/electrumx_int
const kDefaultSparkIndex = 1;
// TODO dart style constants. Maybe move to spark lib?
const MAX_STANDARD_TX_WEIGHT = 400000;
//https://github.com/firoorg/sparkmobile/blob/ef2e39aae18ecc49e0ddc63a3183e9764b96012e/include/spark.h#L16
const SPARK_OUT_LIMIT_PER_TX = 16;
const OP_SPARKMINT = 0xd1;
const OP_SPARKSMINT = 0xd2;
const OP_SPARKSPEND = 0xd3;
@ -125,6 +130,47 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface {
Future<TxData> prepareSendSpark({
required TxData txData,
}) async {
// There should be at least one output.
if (!(txData.recipients?.isNotEmpty == true ||
txData.sparkRecipients?.isNotEmpty == true)) {
throw Exception("No recipients provided.");
}
if (txData.sparkRecipients?.isNotEmpty == true &&
txData.sparkRecipients!.length >= SPARK_OUT_LIMIT_PER_TX - 1) {
throw Exception("Spark shielded output limit exceeded.");
}
final transparentSumOut =
(txData.recipients ?? []).map((e) => e.amount).fold(
Amount(
rawValue: BigInt.zero,
fractionDigits: cryptoCurrency.fractionDigits,
),
(p, e) => p + e);
// See SPARK_VALUE_SPEND_LIMIT_PER_TRANSACTION at https://github.com/firoorg/sparkmobile/blob/ef2e39aae18ecc49e0ddc63a3183e9764b96012e/include/spark.h#L17
// and COIN https://github.com/firoorg/sparkmobile/blob/ef2e39aae18ecc49e0ddc63a3183e9764b96012e/bitcoin/amount.h#L17
// Note that as MAX_MONEY is greater than this limit, we can ignore it. See https://github.com/firoorg/sparkmobile/blob/ef2e39aae18ecc49e0ddc63a3183e9764b96012e/bitcoin/amount.h#L31
if (transparentSumOut >
Amount.fromDecimal(
Decimal.parse("10000"),
fractionDigits: cryptoCurrency.fractionDigits,
)) {
throw Exception(
"Spend to transparent address limit exceeded (10,000 Firo per transaction).");
}
final sparkSumOut =
(txData.sparkRecipients ?? []).map((e) => e.amount).fold(
Amount(
rawValue: BigInt.zero,
fractionDigits: cryptoCurrency.fractionDigits,
),
(p, e) => p + e);
final txAmount = transparentSumOut + sparkSumOut;
// fetch spendable spark coins
final coins = await mainDB.isar.sparkCoins
.where()
@ -140,19 +186,6 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface {
final available = info.cachedBalanceTertiary.spendable;
final txAmount = (txData.recipients ?? []).map((e) => e.amount).fold(
Amount(
rawValue: BigInt.zero,
fractionDigits: cryptoCurrency.fractionDigits,
),
(p, e) => p + e) +
(txData.sparkRecipients ?? []).map((e) => e.amount).fold(
Amount(
rawValue: BigInt.zero,
fractionDigits: cryptoCurrency.fractionDigits,
),
(p, e) => p + e);
if (txAmount > available) {
throw Exception("Insufficient Spark balance");
}
@ -583,7 +616,8 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface {
}
}
Future<List<TxData>> createSparkMintTransactions({
// modelled on CSparkWallet::CreateSparkMintTransactions https://github.com/firoorg/firo/blob/39c41e5e7ec634ced3700fe3f4f5509dc2e480d0/src/spark/sparkwallet.cpp#L752
Future<List<TxData>> _createSparkMintTransactions({
required List<UTXO> availableUtxos,
required List<MutableSparkRecipient> outputs,
required bool subtractFeeFromAmount,
@ -593,6 +627,11 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface {
if (outputs.isEmpty) {
throw Exception("Cannot mint without some recipients");
}
// TODO remove when multiple recipients gui is added. Will need to handle
// addresses when confirming the transactions later as well
assert(outputs.length == 1);
BigInt valueToMint =
outputs.map((e) => e.value).reduce((value, element) => value + element);
@ -615,7 +654,9 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface {
// setup some vars
int nChangePosInOut = -1;
int nChangePosRequest = nChangePosInOut;
List<MutableSparkRecipient> outputs_ = outputs.toList();
List<MutableSparkRecipient> outputs_ = outputs
.map((e) => MutableSparkRecipient(e.address, e.value, e.memo))
.toList(); // deep copy
final feesObject = await fees;
final currentHeight = await chainHeight;
final random = Random.secure();
@ -671,8 +712,13 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface {
vin.clear();
vout.clear();
setCoins.clear();
final remainingOutputs = outputs_.toList();
// deep copy
final remainingOutputs = outputs_
.map((e) => MutableSparkRecipient(e.address, e.value, e.memo))
.toList();
final List<MutableSparkRecipient> singleTxOutputs = [];
if (autoMintAll) {
singleTxOutputs.add(
MutableSparkRecipient(
@ -682,7 +728,8 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface {
),
);
} else {
BigInt remainingMintValue = mintedValue;
BigInt remainingMintValue = BigInt.parse(mintedValue.toString());
while (remainingMintValue > BigInt.zero) {
final singleMintValue =
_min(remainingMintValue, remainingOutputs.first.value);
@ -877,7 +924,10 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface {
++i;
}
outputs_ = remainingOutputs;
// deep copy
outputs_ = remainingOutputs
.map((e) => MutableSparkRecipient(e.address, e.value, e.memo))
.toList();
break; // Done, enough fee included.
}
@ -926,12 +976,17 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface {
rethrow;
}
final builtTx = txb.build();
// TODO: see todo at top of this function
assert(outputs.length == 1);
final data = TxData(
// TODO: add fee output to recipients?
sparkRecipients: vout
.where((e) => e.$1 is Uint8List) // ignore change
.map(
(e) => (
address: "lol",
address: outputs.first
.address, // for display purposes on confirm tx screen. See todos above
memo: "",
amount: Amount(
rawValue: BigInt.from(e.$2),
@ -947,6 +1002,7 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface {
rawValue: nFeeRet,
fractionDigits: cryptoCurrency.fractionDigits,
),
usedUTXOs: vin.map((e) => e.utxo).toList(),
);
if (nFeeRet.toInt() < data.vSize!) {
@ -1030,7 +1086,7 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface {
throw Exception("No available UTXOs found to anonymize");
}
final results = await createSparkMintTransactions(
final mints = await _createSparkMintTransactions(
subtractFeeFromAmount: subtractFeeFromAmount,
autoMintAll: true,
availableUtxos: spendableUtxos,
@ -1045,9 +1101,7 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface {
],
);
for (final data in results) {
await confirmSparkMintTransaction(txData: data);
}
await confirmSparkMintTransactions(txData: TxData(sparkMints: mints));
} catch (e, s) {
Logging.instance.log(
"Exception caught in anonymizeAllSpark(): $e\n$s",
@ -1061,196 +1115,98 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface {
///
/// See https://docs.google.com/document/d/1RG52GoYTZDvKlZz_3G4sQu-PpT6JWSZGHLNswWcrE3o
Future<TxData> prepareSparkMintTransaction({required TxData txData}) async {
// "this kind of transaction is generated like a regular transaction, but in
// place of [regular] outputs we put spark outputs... we construct the input
// part of the transaction first then we generate spark related data [and]
// we sign like regular transactions at the end."
// Validate inputs.
// There should be at least one input.
if (txData.utxos == null || txData.utxos!.isEmpty) {
throw Exception("No inputs provided.");
}
// Validate individual inputs.
for (final utxo in txData.utxos!) {
// Input amount must be greater than zero.
if (utxo.value == 0) {
throw Exception("Input value cannot be zero.");
}
// Input value must be greater than dust limit.
if (BigInt.from(utxo.value) < cryptoCurrency.dustLimit.raw) {
throw Exception("Input value below dust limit.");
}
}
// Validate outputs.
// There should be at least one output.
if (txData.recipients == null || txData.recipients!.isEmpty) {
throw Exception("No recipients provided.");
}
// For now let's limit to one output.
if (txData.recipients!.length > 1) {
throw Exception("Only one recipient supported.");
// TODO remove and test with multiple recipients.
}
// Limit outputs per tx to 16.
//
// See SPARK_OUT_LIMIT_PER_TX at https://github.com/firoorg/sparkmobile/blob/ef2e39aae18ecc49e0ddc63a3183e9764b96012e/include/spark.h#L16
if (txData.recipients!.length > 16) {
throw Exception("Too many recipients.");
}
// Limit spend value per tx to 1000000000000 satoshis.
//
// See SPARK_VALUE_SPEND_LIMIT_PER_TRANSACTION at https://github.com/firoorg/sparkmobile/blob/ef2e39aae18ecc49e0ddc63a3183e9764b96012e/include/spark.h#L17
// and COIN https://github.com/firoorg/sparkmobile/blob/ef2e39aae18ecc49e0ddc63a3183e9764b96012e/bitcoin/amount.h#L17
// Note that as MAX_MONEY is greater than this limit, we can ignore it. See https://github.com/firoorg/sparkmobile/blob/ef2e39aae18ecc49e0ddc63a3183e9764b96012e/bitcoin/amount.h#L31
//
// This will be added to and checked as we validate outputs.
Amount totalAmount = Amount(
rawValue: BigInt.zero,
fractionDigits: cryptoCurrency.fractionDigits,
);
// Validate individual outputs.
for (final recipient in txData.recipients!) {
// Output amount must be greater than zero.
if (recipient.amount.raw == BigInt.zero) {
throw Exception("Output amount cannot be zero.");
// Could refactor this for loop to use an index and remove this output.
}
// Output amount must be greater than dust limit.
if (recipient.amount < cryptoCurrency.dustLimit) {
throw Exception("Output below dust limit.");
}
// Do not add outputs that would exceed the spend limit.
totalAmount += recipient.amount;
if (totalAmount.raw > BigInt.from(1000000000000)) {
throw Exception(
"Spend limit exceeded (10,000 FIRO per tx).",
);
}
}
// Create a transaction builder and set locktime and version.
final txb = btc.TransactionBuilder(
network: _bitcoinDartNetwork,
);
txb.setLockTime(await chainHeight);
txb.setVersion(1);
final signingData = await fetchBuildTxData(txData.utxos!.toList());
// Create the serial context.
//
// "...serial_context is a byte array, which should be unique for each
// transaction, and for that we serialize and put all inputs into
// serial_context vector."
final serialContext = LibSpark.serializeMintContext(
inputs: signingData
.map((e) => (
e.utxo.txid,
e.utxo.vout,
))
.toList(),
);
// Add inputs.
for (final sd in signingData) {
txb.addInput(
sd.utxo.txid,
sd.utxo.vout,
0xffffffff -
1, // minus 1 is important. 0xffffffff on its own will burn funds
sd.output,
);
}
// Create mint recipients.
final mintRecipients = LibSpark.createSparkMintRecipients(
outputs: txData.recipients!
.map((e) => (
sparkAddress: e.address,
value: e.amount.raw.toInt(),
memo: "",
))
.toList(),
serialContext: Uint8List.fromList(serialContext),
generate: true,
);
// Add mint output(s).
for (final mint in mintRecipients) {
txb.addOutput(
mint.scriptPubKey,
mint.amount,
);
}
try {
// Sign the transaction accordingly
for (var i = 0; i < signingData.length; i++) {
txb.sign(
vin: i,
keyPair: signingData[i].keyPair!,
witnessValue: signingData[i].utxo.value,
redeemScript: signingData[i].redeemScript,
);
if (txData.sparkRecipients?.isNotEmpty != true) {
throw Exception("Missing spark recipients.");
}
final recipients = txData.sparkRecipients!
.map(
(e) => MutableSparkRecipient(
e.address,
e.amount.raw,
e.memo,
),
)
.toList();
final total = recipients
.map((e) => e.value)
.reduce((value, element) => value += element);
if (total < BigInt.zero) {
throw Exception("Attempted send of negative amount");
} else if (total == BigInt.zero) {
throw Exception("Attempted send of zero amount");
}
final currentHeight = await chainHeight;
// coin control not enabled for firo currently so we can ignore this
// final utxosToUse = txData.utxos?.toList() ?? await mainDB.isar.utxos
// .where()
// .walletIdEqualTo(walletId)
// .filter()
// .isBlockedEqualTo(false)
// .and()
// .group((q) => q.usedEqualTo(false).or().usedIsNull())
// .and()
// .valueGreaterThan(0)
// .findAll();
final spendableUtxos = await mainDB.isar.utxos
.where()
.walletIdEqualTo(walletId)
.filter()
.isBlockedEqualTo(false)
.and()
.group((q) => q.usedEqualTo(false).or().usedIsNull())
.and()
.valueGreaterThan(0)
.findAll();
spendableUtxos.removeWhere(
(e) => !e.isConfirmed(
currentHeight,
cryptoCurrency.minConfirms,
),
);
if (spendableUtxos.isEmpty) {
throw Exception("No available UTXOs found to anonymize");
}
final available = spendableUtxos
.map((e) => BigInt.from(e.value))
.reduce((value, element) => value += element);
final bool subtractFeeFromAmount;
if (available < total) {
throw Exception("Insufficient balance");
} else if (available == total) {
subtractFeeFromAmount = true;
} else {
subtractFeeFromAmount = false;
}
final mints = await _createSparkMintTransactions(
subtractFeeFromAmount: subtractFeeFromAmount,
autoMintAll: false,
availableUtxos: spendableUtxos,
outputs: recipients,
);
return txData.copyWith(sparkMints: mints);
} catch (e, s) {
Logging.instance.log(
"Caught exception while signing spark mint transaction: $e\n$s",
level: LogLevel.Error,
"Exception caught in prepareSparkMintTransaction(): $e\n$s",
level: LogLevel.Warning,
);
rethrow;
}
final builtTx = txb.build();
// TODO any changes to this txData object required?
return txData.copyWith(
// recipients: [
// (
// amount: Amount(
// rawValue: BigInt.from(incomplete.outs[0].value!),
// fractionDigits: cryptoCurrency.fractionDigits,
// ),
// address: "no address for lelantus mints",
// )
// ],
vSize: builtTx.virtualSize(),
txid: builtTx.getId(),
raw: builtTx.toHex(),
);
}
/// Broadcast a tx and TODO update Spark balance.
Future<TxData> confirmSparkMintTransaction({required TxData txData}) async {
// Broadcast tx.
final txid = await electrumXClient.broadcastTransaction(
rawTx: txData.raw!,
);
// Check txid.
if (txid == txData.txid!) {
print("SPARK TXIDS MATCH!!");
} else {
print("SUBMITTED SPARK TXID DOES NOT MATCH WHAT WE GENERATED");
}
// TODO update spark balance.
return txData.copyWith(
txid: txid,
);
Future<TxData> confirmSparkMintTransactions({required TxData txData}) async {
final futures = txData.sparkMints!.map((e) => confirmSend(txData: e));
return txData.copyWith(sparkMints: await Future.wait(futures));
}
@override
@ -1259,7 +1215,8 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface {
// what ever class this mixin is used on uses LelantusInterface as well)
final normalBalanceFuture = super.updateBalance();
// todo: spark balance aka update info.tertiaryBalance
// todo: spark balance aka update info.tertiaryBalance here?
// currently happens on spark coins update/refresh
// wait for normalBalanceFuture to complete before returning
await normalBalanceFuture;
@ -1477,4 +1434,9 @@ class MutableSparkRecipient {
String memo;
MutableSparkRecipient(this.address, this.value, this.memo);
@override
String toString() {
return 'MutableSparkRecipient{ address: $address, value: $value, memo: $memo }';
}
}