diff --git a/docs/building.md b/docs/building.md index 13cf5c03f..d4d3ad6f4 100644 --- a/docs/building.md +++ b/docs/building.md @@ -205,4 +205,4 @@ Run with `-v` or `--verbose` to see a more detailed error. Certain exceptions ( ## Tor -To test Tor usage, run Stack Wallet from Android Studio. Click the Flutter DevTools icon in the Run tab (next to the Hot Reload and Hot Restart buttons) and navigate to the Network tab. Connections using Tor will show as `GET InternetAddress('127.0.0.1', IPv4) 101 ws`. Connections outside of Tor will show the destination address directly. +To test Tor usage, run Stack Wallet from Android Studio. Click the Flutter DevTools icon in the Run tab (next to the Hot Reload and Hot Restart buttons) and navigate to the Network tab. Connections using Tor will show as `GET InternetAddress('127.0.0.1', IPv4) 101 ws`. Connections outside of Tor will show the destination address directly (although some Tor requests may also show the destination address directly, check the Headers take for *eg.* `{localPort: 59940, remoteAddress: 127.0.0.1, remotePort: 6725}`. `localPort` should match your Tor port. diff --git a/lib/models/isar/models/blockchain_data/address.dart b/lib/models/isar/models/blockchain_data/address.dart index 16516edf4..a5bd64431 100644 --- a/lib/models/isar/models/blockchain_data/address.dart +++ b/lib/models/isar/models/blockchain_data/address.dart @@ -164,6 +164,7 @@ enum AddressType { stellar, tezos, frostMS, + solana, p2tr; String get readableName { @@ -196,6 +197,8 @@ enum AddressType { return "Tezos"; case AddressType.frostMS: return "FrostMS"; + case AddressType.solana: + return "Solana"; case AddressType.p2tr: return "Taproot"; // Why not use P2TR, P2PKH, etc.? } diff --git a/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart b/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart index 3d6c4b8df..d84bfc708 100644 --- a/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart +++ b/lib/pages/add_wallet_views/add_wallet_view/add_wallet_view.dart @@ -139,6 +139,11 @@ class _AddWalletViewState extends ConsumerState { _coins.remove(Coin.bitcoinFrost); } + // Remove Solana from the list of coins based on our frostEnabled preference. + if (!ref.read(prefsChangeNotifierProvider).solanaEnabled) { + _coins.remove(Coin.solana); + } + coinEntities.addAll(_coins.map((e) => CoinEntity(e))); if (ref.read(prefsChangeNotifierProvider).showTestNetCoins) { 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 6ebafad4a..0073d94ac 100644 --- a/lib/pages/settings_views/global_settings_view/hidden_settings.dart +++ b/lib/pages/settings_views/global_settings_view/hidden_settings.dart @@ -10,6 +10,7 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -276,6 +277,36 @@ class HiddenSettings extends StatelessWidget { const SizedBox( height: 12, ), + Consumer( + builder: (_, ref, __) { + return GestureDetector( + onTap: () async { + ref + .read(prefsChangeNotifierProvider) + .solanaEnabled = + !(ref + .read(prefsChangeNotifierProvider) + .solanaEnabled); + if (kDebugMode) { + print( + "Solana enabled: ${ref.read(prefsChangeNotifierProvider).solanaEnabled}"); + } + }, + child: RoundedWhiteContainer( + child: Text( + "Toggle Solana", + style: STextStyles.button(context).copyWith( + color: Theme.of(context) + .extension()! + .accentColorDark), + ), + ), + ); + }, + ), + const SizedBox( + height: 12, + ), Consumer( builder: (_, ref, __) { return GestureDetector( diff --git a/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart b/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart index 17a5e684a..ef7ae7f31 100644 --- a/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart +++ b/lib/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart @@ -14,6 +14,7 @@ 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:solana/solana.dart'; import 'package:stackwallet/models/node_model.dart'; import 'package:stackwallet/notifications/show_flush_bar.dart'; import 'package:stackwallet/providers/global/secure_store_provider.dart'; @@ -216,6 +217,20 @@ class _AddEditNodeViewState extends ConsumerState { ); } catch (_) {} break; + + case Coin.solana: + try { + RpcClient rpcClient; + if (formData.host!.startsWith("http") || formData.host!.startsWith("https")) { + rpcClient = RpcClient("${formData.host}:${formData.port}"); + } else { + rpcClient = RpcClient("http://${formData.host}:${formData.port}"); + } + await rpcClient.getEpochInfo().then((value) => testPassed = true); + } catch (_) { + testPassed = false; + } + break; } if (showFlushBar && mounted) { @@ -756,6 +771,7 @@ class _NodeFormState extends ConsumerState { case Coin.nano: case Coin.banano: case Coin.eCash: + case Coin.solana: case Coin.stellar: case Coin.stellarTestnet: case Coin.bitcoinFrost: diff --git a/lib/pages/settings_views/global_settings_view/manage_nodes_views/node_details_view.dart b/lib/pages/settings_views/global_settings_view/manage_nodes_views/node_details_view.dart index 95868d5c6..c5516db55 100644 --- a/lib/pages/settings_views/global_settings_view/manage_nodes_views/node_details_view.dart +++ b/lib/pages/settings_views/global_settings_view/manage_nodes_views/node_details_view.dart @@ -13,6 +13,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:solana/solana.dart'; import 'package:stackwallet/notifications/show_flush_bar.dart'; import 'package:stackwallet/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart'; import 'package:stackwallet/providers/global/secure_store_provider.dart'; @@ -193,6 +194,20 @@ class _NodeDetailsViewState extends ConsumerState { testPassed = false; } break; + + case Coin.solana: + try { + RpcClient rpcClient; + if (node!.host.startsWith("http") || node.host.startsWith("https")) { + rpcClient = RpcClient("${node.host}:${node.port}"); + } else { + rpcClient = RpcClient("http://${node.host}:${node.port}"); + } + await rpcClient.getEpochInfo().then((value) => testPassed = true); + } catch (_) { + testPassed = false; + } + break; } if (testPassed) { diff --git a/lib/services/price.dart b/lib/services/price.dart index 25a3f154b..296537cb4 100644 --- a/lib/services/price.dart +++ b/lib/services/price.dart @@ -101,7 +101,7 @@ class PriceAPI { "https://api.coingecko.com/api/v3/coins/markets?vs_currency" "=${baseCurrency.toLowerCase()}" "&ids=monero,bitcoin,litecoin,ecash,epic-cash,zcoin,dogecoin," - "bitcoin-cash,namecoin,wownero,ethereum,particl,nano,banano,stellar,tezos" + "bitcoin-cash,namecoin,wownero,ethereum,particl,nano,banano,stellar,tezos,solana" "&order=market_cap_desc&per_page=50&page=1&sparkline=false"); final coinGeckoResponse = await client.get( diff --git a/lib/themes/color_theme.dart b/lib/themes/color_theme.dart index 38de6c636..c67f08ce8 100644 --- a/lib/themes/color_theme.dart +++ b/lib/themes/color_theme.dart @@ -28,6 +28,7 @@ class CoinThemeColorDefault { Color get namecoin => const Color(0xFF91B1E1); Color get wownero => const Color(0xFFED80C1); Color get particl => const Color(0xFF8175BD); + Color get solana => const Color(0xFFC696FF); Color get stellar => const Color(0xFF6600FF); Color get nano => const Color(0xFF209CE9); Color get banano => const Color(0xFFFBDD11); @@ -66,6 +67,8 @@ class CoinThemeColorDefault { return wownero; case Coin.particl: return particl; + case Coin.solana: + return solana; case Coin.stellar: case Coin.stellarTestnet: return stellar; diff --git a/lib/themes/stack_colors.dart b/lib/themes/stack_colors.dart index 0cc83b04f..1f994934e 100644 --- a/lib/themes/stack_colors.dart +++ b/lib/themes/stack_colors.dart @@ -1709,6 +1709,8 @@ class StackColors extends ThemeExtension { return _coin.wownero; case Coin.particl: return _coin.particl; + case Coin.solana: + return _coin.solana; case Coin.stellar: case Coin.stellarTestnet: return _coin.stellar; diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index 672da9059..722d9f9af 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -26,6 +26,7 @@ import 'package:stackwallet/wallets/crypto_currency/coins/monero.dart'; import 'package:stackwallet/wallets/crypto_currency/coins/namecoin.dart'; import 'package:stackwallet/wallets/crypto_currency/coins/nano.dart'; import 'package:stackwallet/wallets/crypto_currency/coins/particl.dart'; +import 'package:stackwallet/wallets/crypto_currency/coins/solana.dart'; import 'package:stackwallet/wallets/crypto_currency/coins/stellar.dart'; import 'package:stackwallet/wallets/crypto_currency/coins/tezos.dart'; import 'package:stackwallet/wallets/crypto_currency/coins/wownero.dart'; @@ -67,6 +68,8 @@ class AddressUtils { return Namecoin(CryptoCurrencyNetwork.main).validateAddress(address); case Coin.particl: return Particl(CryptoCurrencyNetwork.main).validateAddress(address); + case Coin.solana: + return Solana(CryptoCurrencyNetwork.main).validateAddress(address); case Coin.stellar: return Stellar(CryptoCurrencyNetwork.main).validateAddress(address); case Coin.nano: diff --git a/lib/utilities/amount/amount_unit.dart b/lib/utilities/amount/amount_unit.dart index 87efcc4cd..cfa9fec30 100644 --- a/lib/utilities/amount/amount_unit.dart +++ b/lib/utilities/amount/amount_unit.dart @@ -55,6 +55,7 @@ enum AmountUnit { case Coin.stellar: // TODO: check if this is correct case Coin.stellarTestnet: case Coin.tezos: + case Coin.solana: return AmountUnit.values.sublist(0, 4); case Coin.monero: diff --git a/lib/utilities/block_explorers.dart b/lib/utilities/block_explorers.dart index 941f7c599..fcb2f5717 100644 --- a/lib/utilities/block_explorers.dart +++ b/lib/utilities/block_explorers.dart @@ -66,6 +66,8 @@ Uri getDefaultBlockExplorerUrlFor({ return Uri.parse("https://testnet.stellarchain.io/transactions/$txid"); case Coin.tezos: return Uri.parse("https://tzstats.com/$txid"); + case Coin.solana: + return Uri.parse("https://explorer.solana.com/tx/$txid"); } } diff --git a/lib/utilities/constants.dart b/lib/utilities/constants.dart index f7a6faeb2..f60a0fe1e 100644 --- a/lib/utilities/constants.dart +++ b/lib/utilities/constants.dart @@ -46,6 +46,7 @@ abstract class Constants { 10000000); // https://developers.stellar.org/docs/fundamentals-and-concepts/stellar-data-structures/assets#amount-precision static final BigInt _satsPerCoin = BigInt.from(100000000); static final BigInt _satsPerCoinTezos = BigInt.from(1000000); + static final BigInt _satsPerCoinSolana = BigInt.from(1000000000); static const int _decimalPlaces = 8; static const int _decimalPlacesNano = 30; static const int _decimalPlacesBanano = 29; @@ -55,6 +56,7 @@ abstract class Constants { static const int _decimalPlacesECash = 2; static const int _decimalPlacesStellar = 7; static const int _decimalPlacesTezos = 6; + static const int _decimalPlacesSolana = 9; static const int notificationsMax = 0xFFFFFFFF; static const Duration networkAliveTimerDuration = Duration(seconds: 10); @@ -109,6 +111,9 @@ abstract class Constants { case Coin.tezos: return _satsPerCoinTezos; + + case Coin.solana: + return _satsPerCoinSolana; } } @@ -155,6 +160,9 @@ abstract class Constants { case Coin.tezos: return _decimalPlacesTezos; + + case Coin.solana: + return _decimalPlacesSolana; } } @@ -176,6 +184,7 @@ abstract class Constants { case Coin.ethereum: case Coin.namecoin: case Coin.particl: + case Coin.solana: case Coin.nano: case Coin.stellar: case Coin.stellarTestnet: @@ -245,6 +254,7 @@ abstract class Constants { case Coin.nano: // TODO: Verify this case Coin.banano: // TODO: Verify this + case Coin.solana: return 1; case Coin.stellar: @@ -272,6 +282,7 @@ abstract class Constants { case Coin.namecoin: case Coin.particl: case Coin.ethereum: + case Coin.solana: return 12; case Coin.wownero: diff --git a/lib/utilities/default_nodes.dart b/lib/utilities/default_nodes.dart index b8f296b68..25d730c43 100644 --- a/lib/utilities/default_nodes.dart +++ b/lib/utilities/default_nodes.dart @@ -188,6 +188,18 @@ abstract class DefaultNodes { isDown: false, ); + static NodeModel get solana => NodeModel( + host: "https://api.mainnet-beta.solana.com", // TODO: Change this to stack wallet one + port: 443, + name: DefaultNodes.defaultName, + id: DefaultNodes.buildId(Coin.solana), + useSSL: true, + enabled: true, + coinName: Coin.solana.name, + isFailover: true, + isDown: false, + ); + static NodeModel get stellar => NodeModel( host: "https://horizon.stellar.org", port: 443, @@ -348,6 +360,9 @@ abstract class DefaultNodes { case Coin.particl: return particl; + case Coin.solana: + return solana; + case Coin.stellar: return stellar; diff --git a/lib/utilities/enums/coin_enum.dart b/lib/utilities/enums/coin_enum.dart index abb6985ea..6551dbc62 100644 --- a/lib/utilities/enums/coin_enum.dart +++ b/lib/utilities/enums/coin_enum.dart @@ -26,6 +26,7 @@ enum Coin { namecoin, nano, particl, + solana, stellar, tezos, wownero, @@ -69,6 +70,8 @@ extension CoinExt on Coin { return "Monero"; case Coin.particl: return "Particl"; + case Coin.solana: + return "Solana"; case Coin.stellar: return "Stellar"; case Coin.tezos: @@ -121,6 +124,8 @@ extension CoinExt on Coin { return "XMR"; case Coin.particl: return "PART"; + case Coin.solana: + return "SOL"; case Coin.stellar: return "XLM"; case Coin.tezos: @@ -173,6 +178,8 @@ extension CoinExt on Coin { return "monero"; case Coin.particl: return "particl"; + case Coin.solana: + return "solana"; case Coin.stellar: return "stellar"; case Coin.tezos: @@ -229,6 +236,7 @@ extension CoinExt on Coin { case Coin.nano: case Coin.banano: case Coin.tezos: + case Coin.solana: return false; } } @@ -259,6 +267,7 @@ extension CoinExt on Coin { case Coin.firoTestNet: case Coin.nano: case Coin.banano: + case Coin.solana: case Coin.stellar: case Coin.stellarTestnet: return false; @@ -284,6 +293,7 @@ extension CoinExt on Coin { case Coin.banano: case Coin.eCash: case Coin.stellar: + case Coin.solana: return false; case Coin.dogecoinTestNet: @@ -327,6 +337,7 @@ extension CoinExt on Coin { case Coin.banano: case Coin.eCash: case Coin.stellar: + case Coin.solana: return this; case Coin.dogecoinTestNet: @@ -400,6 +411,9 @@ extension CoinExt on Coin { case Coin.stellar: case Coin.stellarTestnet: return AddressType.stellar; + + case Coin.solana: + return AddressType.solana; } } } @@ -448,6 +462,10 @@ Coin coinFromPrettyName(String name) { case "particl": return Coin.particl; + case "Solana": + case "solana": + return Coin.solana; + case "Stellar": case "stellar": return Coin.stellar; @@ -548,6 +566,8 @@ Coin coinFromTickerCaseInsensitive(String ticker) { return Coin.namecoin; case "part": return Coin.particl; + case "sol": + return Coin.solana; case "xlm": return Coin.stellar; case "xtz": diff --git a/lib/utilities/enums/derive_path_type_enum.dart b/lib/utilities/enums/derive_path_type_enum.dart index 95b5d9abb..f830bc077 100644 --- a/lib/utilities/enums/derive_path_type_enum.dart +++ b/lib/utilities/enums/derive_path_type_enum.dart @@ -17,6 +17,7 @@ enum DerivePathType { bip84, eth, eCash44, + solana, bip86, } @@ -45,6 +46,9 @@ extension DerivePathTypeExt on DerivePathType { case Coin.ethereum: // TODO: do we need something here? return DerivePathType.eth; + case Coin.solana: + return DerivePathType.solana; + case Coin.bitcoinFrost: case Coin.bitcoinFrostTestNet: case Coin.epicCash: diff --git a/lib/utilities/prefs.dart b/lib/utilities/prefs.dart index 8fbbbf069..d380901c3 100644 --- a/lib/utilities/prefs.dart +++ b/lib/utilities/prefs.dart @@ -68,6 +68,7 @@ class Prefs extends ChangeNotifier { await _setMaxDecimals(); _useTor = await _getUseTor(); _fusionServerInfo = await _getFusionServerInfo(); + _solanaEnabled = await _getSolanaEnabled(); _frostEnabled = await _getFrostEnabled(); _initialized = true; @@ -1010,6 +1011,27 @@ class Prefs extends ChangeNotifier { return actualMap; } + // Solana + + bool _solanaEnabled = false; + + bool get solanaEnabled => _solanaEnabled; + + set solanaEnabled(bool solanaEnabled) { + if (_solanaEnabled != solanaEnabled) { + DB.instance.put( + boxName: DB.boxNamePrefs, key: "solanaEnabled", value: solanaEnabled); + _solanaEnabled = solanaEnabled; + notifyListeners(); + } + } + + Future _getSolanaEnabled() async { + return await DB.instance.get( + boxName: DB.boxNamePrefs, key: "solanaEnabled") as bool? ?? + false; + } + // FROST multisig bool _frostEnabled = false; diff --git a/lib/wallets/crypto_currency/coins/solana.dart b/lib/wallets/crypto_currency/coins/solana.dart new file mode 100644 index 000000000..632e0f977 --- /dev/null +++ b/lib/wallets/crypto_currency/coins/solana.dart @@ -0,0 +1,48 @@ +import 'package:solana/solana.dart'; +import 'package:stackwallet/models/node_model.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/crypto_currency/intermediate/bip39_currency.dart'; +import 'package:stackwallet/utilities/default_nodes.dart'; + +class Solana extends Bip39Currency { + Solana(super.network) { + switch (network) { + case CryptoCurrencyNetwork.main: + coin = Coin.solana; + default: + throw Exception("Unsupported network: $network"); + } + } + + @override + NodeModel get defaultNode { + switch (network) { + case CryptoCurrencyNetwork.main: + return NodeModel( + host: "https://api.mainnet-beta.solana.com/", // TODO: Change this to stack wallet one + port: 443, + name: DefaultNodes.defaultName, + id: DefaultNodes.buildId(Coin.solana), + useSSL: true, + enabled: true, + coinName: Coin.solana.name, + isFailover: true, + isDown: false, + ); + default: + throw Exception("Unsupported network: $network"); + } + } + + @override + int get minConfirms => 21; + + @override + bool validateAddress(String address) { + return isPointOnEd25519Curve(Ed25519HDPublicKey.fromBase58(address).toByteArray()); + } + + @override + String get genesisHash => throw UnimplementedError(); +} \ No newline at end of file diff --git a/lib/wallets/wallet/impl/solana_wallet.dart b/lib/wallets/wallet/impl/solana_wallet.dart new file mode 100644 index 000000000..2dd21b7ca --- /dev/null +++ b/lib/wallets/wallet/impl/solana_wallet.dart @@ -0,0 +1,431 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:isar/isar.dart'; +import 'package:solana/dto.dart'; +import 'package:solana/solana.dart'; +import 'package:stackwallet/models/balance.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/transaction.dart' + as isar; +import 'package:stackwallet/models/isar/models/isar_models.dart'; +import 'package:stackwallet/models/node_model.dart'; +import 'package:stackwallet/models/paymint/fee_object_model.dart'; +import 'package:stackwallet/services/node_service.dart'; +import 'package:stackwallet/services/tor_service.dart'; +import 'package:stackwallet/utilities/amount/amount.dart'; +import 'package:stackwallet/utilities/default_nodes.dart'; +import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/enums/fee_rate_type_enum.dart'; +import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/wallets/crypto_currency/coins/solana.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/models/tx_data.dart'; +import 'package:stackwallet/wallets/wallet/intermediate/bip39_wallet.dart'; +import 'package:tuple/tuple.dart'; + +class SolanaWallet extends Bip39Wallet { + SolanaWallet(CryptoCurrencyNetwork network) : super(Solana(network)); + + NodeModel? _solNode; + + RpcClient? rpcClient; // The Solana RpcClient. + + Future _getKeyPair() async { + return Ed25519HDKeyPair.fromMnemonic(await getMnemonic(), + account: 0, change: 0); + } + + Future
_getCurrentAddress() async { + var addressStruct = Address( + walletId: walletId, + value: (await _getKeyPair()).address, + publicKey: List.empty(), + derivationIndex: 0, + derivationPath: DerivationPath()..value = "m/44'/501'/0'/0'", + type: cryptoCurrency.coin.primaryAddressType, + subType: AddressSubType.unknown); + return addressStruct; + } + + Future _getCurrentBalanceInLamports() async { + await _checkClient(); + var balance = await rpcClient?.getBalance((await _getKeyPair()).address); + return balance!.value; + } + + @override + FilterOperation? get changeAddressFilterOperation => + throw UnimplementedError(); + + @override + Future checkSaveInitialReceivingAddress() async { + try { + var address = (await _getKeyPair()).address; + + await mainDB.updateOrPutAddresses([ + Address( + walletId: walletId, + value: address, + publicKey: List.empty(), + derivationIndex: 0, + derivationPath: DerivationPath()..value = "m/44'/501'/0'/0'", + type: cryptoCurrency.coin.primaryAddressType, + subType: AddressSubType.unknown) + ]); + } catch (e, s) { + Logging.instance.log( + "$runtimeType checkSaveInitialReceivingAddress() failed: $e\n$s", + level: LogLevel.Error, + ); + } + } + + @override + Future prepareSend({required TxData txData}) async { + try { + await _checkClient(); + + if (txData.recipients == null || txData.recipients!.length != 1) { + throw Exception("$runtimeType prepareSend requires 1 recipient"); + } + + Amount sendAmount = txData.amount!; + + if (sendAmount > info.cachedBalance.spendable) { + throw Exception("Insufficient available balance"); + } + + int feeAmount; + var currentFees = await fees; + switch (txData.feeRateType) { + case FeeRateType.fast: + feeAmount = currentFees.fast; + break; + case FeeRateType.slow: + feeAmount = currentFees.slow; + break; + case FeeRateType.average: + default: + feeAmount = currentFees.medium; + break; + } + + // Rent exemption of Solana + final accInfo = + await rpcClient?.getAccountInfo((await _getKeyPair()).address); + int minimumRent = await rpcClient?.getMinimumBalanceForRentExemption( + accInfo!.value!.data.toString().length) ?? + 0; // TODO revisit null condition. + if (minimumRent > + ((await _getCurrentBalanceInLamports()) - + txData.amount!.raw.toInt() - + feeAmount)) { + throw Exception( + "Insufficient remaining balance for rent exemption, minimum rent: ${minimumRent / pow(10, cryptoCurrency.fractionDigits)}"); + } + + return txData.copyWith( + fee: Amount( + rawValue: BigInt.from(feeAmount), + fractionDigits: cryptoCurrency.fractionDigits, + ), + ); + } catch (e, s) { + Logging.instance.log( + "$runtimeType Solana prepareSend failed: $e\n$s", + level: LogLevel.Error, + ); + rethrow; + } + } + + @override + Future confirmSend({required TxData txData}) async { + try { + await _checkClient(); + + final keyPair = await _getKeyPair(); + var recipientAccount = txData.recipients!.first; + var recipientPubKey = + Ed25519HDPublicKey.fromBase58(recipientAccount.address); + final message = Message( + instructions: [ + SystemInstruction.transfer( + fundingAccount: keyPair.publicKey, + recipientAccount: recipientPubKey, + lamports: txData.amount!.raw.toInt()), + ComputeBudgetInstruction.setComputeUnitPrice( + microLamports: txData.fee!.raw.toInt()), + ], + ); + + final txid = await rpcClient?.signAndSendTransaction(message, [keyPair]); + return txData.copyWith( + txid: txid, + ); + } catch (e, s) { + Logging.instance.log( + "$runtimeType Solana confirmSend failed: $e\n$s", + level: LogLevel.Error, + ); + rethrow; + } + } + + @override + Future estimateFeeFor(Amount amount, int feeRate) async { + await _checkClient(); + + if (info.cachedBalance.spendable.raw == BigInt.zero) { + return Amount( + rawValue: BigInt.zero, + fractionDigits: cryptoCurrency.fractionDigits, + ); + } + + final fee = await rpcClient?.getFees(); + // TODO [prio=low]: handle null fee. + + return Amount( + rawValue: BigInt.from(fee!.value.feeCalculator.lamportsPerSignature), + fractionDigits: cryptoCurrency.fractionDigits, + ); + } + + @override + Future get fees async { + await _checkClient(); + + final fees = await rpcClient?.getFees(); + // TODO [prio=low]: handle null fees. + return FeeObject( + numberOfBlocksFast: 1, + numberOfBlocksAverage: 1, + numberOfBlocksSlow: 1, + fast: fees!.value.feeCalculator.lamportsPerSignature, + medium: fees!.value.feeCalculator.lamportsPerSignature, + slow: fees!.value.feeCalculator.lamportsPerSignature); + } + + @override + Future pingCheck() { + try { + _checkClient(); + rpcClient?.getHealth(); + return Future.value(true); + } catch (e, s) { + Logging.instance.log( + "$runtimeType Solana pingCheck failed: $e\n$s", + level: LogLevel.Error, + ); + return Future.value(false); + } + } + + @override + FilterOperation? get receivingAddressFilterOperation => + FilterGroup.and(standardReceivingAddressFilters); + + @override + Future recover({required bool isRescan}) async { + await refreshMutex.protect(() async { + var addressStruct = await _getCurrentAddress(); + + await mainDB.updateOrPutAddresses([addressStruct]); + + if (info.cachedReceivingAddress != addressStruct.value) { + await info.updateReceivingAddress( + newAddress: addressStruct.value, + isar: mainDB.isar, + ); + } + + await Future.wait([ + updateBalance(), + updateChainHeight(), + updateTransactions(), + ]); + }); + } + + @override + Future updateBalance() async { + try { + await _checkClient(); + + var balance = await rpcClient?.getBalance(info.cachedReceivingAddress); + + // Rent exemption of Solana + final accInfo = + await rpcClient?.getAccountInfo((await _getKeyPair()).address); + // TODO [prio=low]: handle null account info. + final int minimumRent = + await rpcClient?.getMinimumBalanceForRentExemption( + accInfo!.value!.data.toString().length) ?? + 0; + // TODO [prio=low]: revisit null condition. + var spendableBalance = balance!.value - minimumRent; + + final newBalance = Balance( + total: Amount( + rawValue: BigInt.from(balance.value), + fractionDigits: Coin.solana.decimals, + ), + spendable: Amount( + rawValue: BigInt.from(spendableBalance), + fractionDigits: Coin.solana.decimals, + ), + blockedTotal: Amount( + rawValue: BigInt.from(minimumRent), + fractionDigits: Coin.solana.decimals, + ), + pendingSpendable: Amount( + rawValue: BigInt.zero, + fractionDigits: Coin.solana.decimals, + ), + ); + + await info.updateBalance(newBalance: newBalance, isar: mainDB.isar); + } catch (e, s) { + Logging.instance.log( + "Error getting balance in solana_wallet.dart: $e\n$s", + level: LogLevel.Error, + ); + } + } + + @override + Future updateChainHeight() async { + try { + await _checkClient(); + + int blockHeight = await rpcClient?.getSlot() ?? 0; + // TODO [prio=low]: Revisit null condition. + + await info.updateCachedChainHeight( + newHeight: blockHeight, + isar: mainDB.isar, + ); + } catch (e, s) { + Logging.instance.log( + "Error occurred in solana_wallet.dart while getting" + " chain height for solana: $e\n$s", + level: LogLevel.Error, + ); + } + } + + @override + Future updateNode() async { + _solNode = getCurrentNode(); + await refresh(); + } + + @override + NodeModel getCurrentNode() { + return _solNode ?? + NodeService(secureStorageInterface: secureStorageInterface) + .getPrimaryNodeFor(coin: info.coin) ?? + DefaultNodes.getNodeFor(info.coin); + } + + @override + Future updateTransactions() async { + try { + await _checkClient(); + + var transactionsList = await rpcClient?.getTransactionsList( + (await _getKeyPair()).publicKey, + encoding: Encoding.jsonParsed); + var txsList = + List>.empty(growable: true); + + // TODO [prio=low]: Revisit null assertion below. + + for (final tx in transactionsList!) { + var senderAddress = + (tx.transaction as ParsedTransaction).message.accountKeys[0].pubkey; + var receiverAddress = + (tx.transaction as ParsedTransaction).message.accountKeys[1].pubkey; + var txType = isar.TransactionType.unknown; + var txAmount = Amount( + rawValue: + BigInt.from(tx.meta!.postBalances[1] - tx.meta!.preBalances[1]), + fractionDigits: cryptoCurrency.fractionDigits, + ); + + if ((senderAddress == (await _getKeyPair()).address) && + (receiverAddress == (await _getKeyPair()).address)) { + txType = isar.TransactionType.sentToSelf; + } else if (senderAddress == (await _getKeyPair()).address) { + txType = isar.TransactionType.outgoing; + } else if (receiverAddress == (await _getKeyPair()).address) { + txType = isar.TransactionType.incoming; + } + + var transaction = isar.Transaction( + walletId: walletId, + txid: (tx.transaction as ParsedTransaction).signatures[0], + timestamp: tx.blockTime!, + type: txType, + subType: isar.TransactionSubType.none, + amount: tx.meta!.postBalances[1] - tx.meta!.preBalances[1], + amountString: txAmount.toJsonString(), + fee: tx.meta!.fee, + height: tx.slot, + isCancelled: false, + isLelantus: false, + slateId: null, + otherData: null, + inputs: [], + outputs: [], + nonce: null, + numberOfMessages: 0, + ); + + var txAddress = Address( + walletId: walletId, + value: receiverAddress, + publicKey: List.empty(), + derivationIndex: 0, + derivationPath: DerivationPath()..value = "m/44'/501'/0'/0'", + type: AddressType.solana, + subType: txType == isar.TransactionType.outgoing + ? AddressSubType.unknown + : AddressSubType.receiving); + + txsList.add(Tuple2(transaction, txAddress)); + } + await mainDB.addNewTransactionData(txsList, walletId); + } catch (e, s) { + Logging.instance.log( + "Error occurred in solana_wallet.dart while getting" + " transactions for solana: $e\n$s", + level: LogLevel.Error, + ); + } + } + + @override + Future updateUTXOs() { + // No UTXOs in Solana + return Future.value(false); + } + + /// Make sure the Solana RpcClient uses Tor if it's enabled. + /// + /// TODO: Make synchronous. + Future _checkClient() async { + if (prefs.useTor) { + final ({InternetAddress host, int port}) proxyInfo = + TorService.sharedInstance.getProxyInfo(); + // If Tor is enabled, pass the optional proxyInfo to the Solana RpcClient. + rpcClient = RpcClient("${getCurrentNode().host}:${getCurrentNode().port}", + proxyInfo: {'host': proxyInfo.host.address, 'port': proxyInfo.port}); + } else { + rpcClient ??= + RpcClient("${getCurrentNode().host}:${getCurrentNode().port}"); + } + return; + } +} diff --git a/lib/wallets/wallet/wallet.dart b/lib/wallets/wallet/wallet.dart index 00131b88f..f8473450b 100644 --- a/lib/wallets/wallet/wallet.dart +++ b/lib/wallets/wallet/wallet.dart @@ -52,6 +52,8 @@ import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/paynym_interf import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/private_key_interface.dart'; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; +import 'impl/solana_wallet.dart'; + abstract class Wallet { // default to Transaction class. For TransactionV2 set to 2 int get isarTransactionVersion => 1; @@ -362,6 +364,9 @@ abstract class Wallet { case Coin.particl: return ParticlWallet(CryptoCurrencyNetwork.main); + case Coin.solana: + return SolanaWallet(CryptoCurrencyNetwork.main); + case Coin.stellar: return StellarWallet(CryptoCurrencyNetwork.main); case Coin.stellarTestnet: diff --git a/lib/widgets/node_card.dart b/lib/widgets/node_card.dart index 50c01f583..006364342 100644 --- a/lib/widgets/node_card.dart +++ b/lib/widgets/node_card.dart @@ -13,6 +13,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:solana/solana.dart'; import 'package:stackwallet/models/node_model.dart'; import 'package:stackwallet/notifications/show_flush_bar.dart'; import 'package:stackwallet/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart'; @@ -213,6 +214,20 @@ class _NodeCardState extends ConsumerState { testPassed = false; } break; + + case Coin.solana: + try { + RpcClient rpcClient; + if (node.host.startsWith("http") || node.host.startsWith("https")) { + rpcClient = RpcClient("${node.host}:${node.port}"); + } else { + rpcClient = RpcClient("http://${node.host}:${node.port}"); + } + await rpcClient.getEpochInfo().then((value) => testPassed = true); + } catch (_) { + testPassed = false; + } + break; } if (testPassed) { diff --git a/lib/widgets/node_options_sheet.dart b/lib/widgets/node_options_sheet.dart index 5eff8c8d1..a91f63fba 100644 --- a/lib/widgets/node_options_sheet.dart +++ b/lib/widgets/node_options_sheet.dart @@ -13,6 +13,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:solana/solana.dart'; import 'package:stackwallet/models/node_model.dart'; import 'package:stackwallet/notifications/show_flush_bar.dart'; import 'package:stackwallet/pages/settings_views/global_settings_view/manage_nodes_views/add_edit_node_view.dart'; @@ -182,6 +183,20 @@ class NodeOptionsSheet extends ConsumerWidget { case Coin.stellarTestnet: throw UnimplementedError(); //TODO: check network/node + + case Coin.solana: + try { + RpcClient rpcClient; + if (node.host.startsWith("http") || node.host.startsWith("https")) { + rpcClient = RpcClient("${node.host}:${node.port}"); + } else { + rpcClient = RpcClient("http://${node.host}:${node.port}"); + } + await rpcClient.getEpochInfo().then((value) => testPassed = true); + } catch (_) { + testPassed = false; + } + break; } if (testPassed) { diff --git a/pubspec.lock b/pubspec.lock index 31815a754..ad0b25a45 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -158,6 +158,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + borsh_annotation: + dependency: transitive + description: + name: borsh_annotation + sha256: "4a226cf8b7a165ecf8020c0c8d366b2728167fd102ef9b9e89d94d86f89ac57b" + url: "https://pub.dev" + source: hosted + version: "0.3.1+5" bs58check: dependency: "direct main" description: @@ -322,10 +330,10 @@ packages: dependency: transitive description: name: coverage - sha256: "8acabb8306b57a409bf4c83522065672ee13179297a6bb0cb9ead73948df7c76" + sha256: "595a29b55ce82d53398e1bcc2cba525d7bd7c59faeb2d2540e9d42c390cfeeeb" url: "https://pub.dev" source: hosted - version: "1.7.2" + version: "1.6.4" cross_file: dependency: transitive description: @@ -506,6 +514,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.9" + ed25519_hd_key: + dependency: transitive + description: + name: ed25519_hd_key + sha256: c5c9f11a03f5789bf9dcd9ae88d641571c802640851f1cacdb13123f171b3a26 + url: "https://pub.dev" + source: hosted + version: "2.2.1" eip1559: dependency: transitive description: @@ -588,13 +604,13 @@ packages: source: hosted version: "2.1.0" file: - dependency: transitive + dependency: "direct overridden" description: name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "6.1.4" file_picker: dependency: "direct main" description: @@ -815,6 +831,14 @@ packages: description: flutter source: sdk version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d + url: "https://pub.dev" + source: hosted + version: "2.4.1" frontend_server_client: dependency: transitive description: @@ -1041,30 +1065,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" - url: "https://pub.dev" - source: hosted - version: "10.0.0" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 - url: "https://pub.dev" - source: hosted - version: "2.0.1" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 - url: "https://pub.dev" - source: hosted - version: "2.0.1" lelantus: dependency: "direct main" description: @@ -1108,18 +1108,18 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.16" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.5.0" memoize: dependency: transitive description: @@ -1132,10 +1132,10 @@ packages: dependency: "direct main" description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.10.0" mime: dependency: transitive description: @@ -1236,10 +1236,10 @@ packages: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.8.3" path_parsing: dependency: transitive description: @@ -1356,10 +1356,10 @@ packages: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102 url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.2" plugin_platform_interface: dependency: transitive description: @@ -1393,13 +1393,13 @@ packages: source: hosted version: "1.2.0-beta-1" process: - dependency: transitive + dependency: "direct overridden" description: name: process - sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" + sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "4.2.4" protobuf: dependency: transitive description: @@ -1545,10 +1545,10 @@ packages: dependency: "direct main" description: name: socks5_proxy - sha256: e0cba6917cd374de6f6cb0ce081e50e6efc24c61644b8e9f20c8bf8b91bb0b75 + sha256: "1d21b5606169654bbf4cfb904e8e6ed897e9f763358709f87310c757096d909a" url: "https://pub.dev" source: hosted - version: "1.0.3+dev.3" + version: "1.0.4" socks_socket: dependency: "direct main" description: @@ -1558,6 +1558,15 @@ packages: url: "https://github.com/cypherstack/socks_socket.git" source: git version: "0.1.0" + solana: + dependency: "direct main" + description: + path: "packages/solana" + ref: "2d7189d31f1bfd5d6779268c81a897f03f339f5d" + resolved-ref: "2d7189d31f1bfd5d6779268c81a897f03f339f5d" + url: "https://github.com/cypherstack/espresso-cash-public.git" + source: git + version: "0.30.4" source_gen: dependency: transitive description: @@ -1901,10 +1910,10 @@ packages: dependency: transitive description: name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583 url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "11.10.0" wakelock: dependency: "direct main" description: @@ -1998,10 +2007,10 @@ packages: dependency: transitive description: name: webdriver - sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" + sha256: "3c923e918918feeb90c4c9fdf1fe39220fa4c0e8e2c0fffaded174498ef86c49" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.2" webkit_inspection_protocol: dependency: transitive description: @@ -2036,13 +2045,13 @@ packages: source: git version: "0.1.0" xdg_directories: - dependency: transitive + dependency: "direct overridden" description: name: xdg_directories - sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + sha256: bd512f03919aac5f1313eb8249f223bacf4927031bf60b02601f81f687689e86 url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "0.2.0+3" xml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7680653a6..6df07fc23 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -177,6 +177,11 @@ dependencies: url: https://github.com/cypherstack/electrum_adapter.git ref: 9e9441fc1e9ace8907256fff05fe2c607b0933b6 stream_channel: ^2.1.0 + solana: + git: # TODO: Revert to official package once Tor support is merged upstream. + url: https://github.com/cypherstack/espresso-cash-public.git + ref: 2d7189d31f1bfd5d6779268c81a897f03f339f5d # tor branch. + path: packages/solana dev_dependencies: flutter_test: @@ -251,6 +256,12 @@ dependency_overrides: crypto: 3.0.2 analyzer: ^5.2.0 pinenacl: ^0.3.3 + xdg_directories: ^0.2.0 + # flutter_local_notifications_linux: ^0.5.0+1 # Overridden by Solana's package (from espresso_cash + # _public). Disabled for compatibility reasons, may affect Linux desktop notifications. + process: ^4.0.0 + file: ^6.0.0 + http: ^0.13.0 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec