diff --git a/lib/pages/settings_views/global_settings_view/hidden_settings.dart b/lib/pages/settings_views/global_settings_view/hidden_settings.dart index 5310caf8f..0d06fe7e6 100644 --- a/lib/pages/settings_views/global_settings_view/hidden_settings.dart +++ b/lib/pages/settings_views/global_settings_view/hidden_settings.dart @@ -23,8 +23,11 @@ import 'package:stackwallet/themes/stack_colors.dart'; import 'package:stackwallet/utilities/assets.dart'; import 'package:stackwallet/utilities/constants.dart'; import 'package:stackwallet/utilities/default_nodes.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; import 'package:stackwallet/utilities/text_styles.dart'; import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; +import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; import 'package:stackwallet/widgets/background.dart'; import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; import 'package:stackwallet/widgets/onetime_popups/tor_has_been_add_dialog.dart'; @@ -552,6 +555,115 @@ class HiddenSettings extends StatelessWidget { ); }, ), + const SizedBox( + height: 12, + ), + Consumer( + builder: (_, ref, __) { + return GestureDetector( + onTap: () async { + try { + // Run refreshSparkData. + // + // Search wallets for a Firo testnet wallet. + for (final wallet + in ref.read(pWallets).wallets) { + if (!(wallet.info.coin == + Coin.firoTestNet)) { + continue; + } + // This is a Firo testnet wallet. + final walletId = wallet.info.walletId; + + // // Search for `circle chunk...` mnemonic. + // final potentialWallet = + // ref.read(pWallets).getWallet(walletId) + // as MnemonicInterface; + // final mnemonic = await potentialWallet + // .getMnemonicAsWords(); + // if (!(mnemonic[0] == "circle" && + // mnemonic[1] == "chunk)")) { + // // That ain't it. Skip this one. + // return; + // } + // Hardcode key in refreshSparkData instead. + + // Get a Spark interface. + final fusionWallet = ref + .read(pWallets) + .getWallet(walletId) as SparkInterface; + + // Refresh Spark data. + await fusionWallet.refreshSparkData(); + + // We only need to run this once. + break; + } + } catch (e, s) { + print("$e\n$s"); + } + }, + child: RoundedWhiteContainer( + child: Text( + "Refresh Spark wallet", + style: STextStyles.button(context).copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark), + ), + ), + ); + }, + ), + const SizedBox( + height: 12, + ), + Consumer( + builder: (_, ref, __) { + return GestureDetector( + onTap: () async { + try { + // Run prepareSparkMintTransaction. + for (final wallet + in ref.read(pWallets).wallets) { + // Prepare tx with a Firo testnet wallet. + if (!(wallet.info.coin == + Coin.firoTestNet)) { + continue; + } + final walletId = wallet.info.walletId; + + // Get a Spark interface. + final fusionWallet = ref + .read(pWallets) + .getWallet(walletId) as SparkInterface; + + // Make a dummy TxData. + TxData txData = TxData(); // TODO + + await fusionWallet + .prepareSparkMintTransaction( + txData: txData); + + // We only need to run this once. + break; + } + } catch (e, s) { + print("$e\n$s"); + } + }, + child: RoundedWhiteContainer( + child: Text( + "Prepare Spark mint transaction", + style: STextStyles.button(context).copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark), + ), + ), + ); + }, + ), // const SizedBox( // height: 12, // ), diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart index 3547b6284..2f37f3f8c 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart @@ -562,26 +562,144 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface { } } - /// Transparent to Spark (mint) transaction creation + /// Transparent to Spark (mint) transaction creation. + /// + /// See https://docs.google.com/document/d/1RG52GoYTZDvKlZz_3G4sQu-PpT6JWSZGHLNswWcrE3o Future prepareSparkMintTransaction({required TxData txData}) async { - // https://docs.google.com/document/d/1RG52GoYTZDvKlZz_3G4sQu-PpT6JWSZGHLNswWcrE3o/edit + // "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." - // this kind of transaction is generated like a regular transaction, but in - // place of regulart outputs we put spark outputs, so for that we call - // createSparkMintRecipients function, we get spark related data, - // everything else we do like for regular transaction, and we put CRecipient - // object as a tx outputs, we need to keep the order.. - // First we pass spark::MintedCoinData>, has following members, Address - // which is any spark address, amount (v) how much we want to send, and - // memo which can be any string with 32 length (any string we want to send - // to receiver), 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. So 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."); + } + + // For now let's limit to one input. + if (txData.utxos!.length > 1) { + throw Exception("Only one input supported."); + // TODO remove and test with multiple inputs. + } + + // 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: btc.NetworkType( + messagePrefix: cryptoCurrency.networkParams.messagePrefix, + bech32: cryptoCurrency.networkParams.bech32Hrp, + bip32: btc.Bip32Type( + public: cryptoCurrency.networkParams.pubHDPrefix, + private: cryptoCurrency.networkParams.privHDPrefix, + ), + pubKeyHash: cryptoCurrency.networkParams.p2pkhPrefix, + scriptHash: cryptoCurrency.networkParams.p2shPrefix, + wif: cryptoCurrency.networkParams.wifPrefix, + ), + ); + txb.setLockTime(await chainHeight); + txb.setVersion(3 | (9 << 16)); + + // Create a mint script. + final mintScript = bscript.compile([ + 0xd1, // OP_SPARKMINT. + Uint8List(0), + ]); + + // Add inputs. + for (final utxo in txData.utxos!) { + txb.addInput( + utxo.txid, + utxo.vout, + 0xffffffff, + mintScript, + ); + } + + // 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." List serialContext = []; + for (final utxo in txData.utxos!) { + serialContext.addAll( + bscript.compile([ + utxo.txid, + utxo.vout, + ]), + ); + } + // Create mint recipients. final mintRecipients = LibSpark.createSparkMintRecipients( outputs: txData.recipients! .map((e) => ( @@ -591,10 +709,39 @@ mixin SparkInterface on Bip39HDWallet, ElectrumXInterface { )) .toList(), serialContext: Uint8List.fromList(serialContext), + // generate: true // TODO is this needed? ); + + // Add mint output(s). + for (final mint in mintRecipients) { + txb.addOutput( + mint.scriptPubKey, + mint.amount, + ); + } + + // TODO Sign the transaction. + throw UnimplementedError(); } + /// Broadcast a tx and TODO update Spark balance. + Future confirmSparkMintTransaction({required TxData txData}) async { + // Broadcast tx. + final txid = await electrumXClient.broadcastTransaction( + rawTx: txData.raw!, + ); + + // Check txid. + assert(txid == txData.txid!); + + // TODO update spark balance. + + return txData.copyWith( + txid: txid, + ); + } + @override Future updateBalance() async { // call to super to update transparent balance (and lelantus balance if