stack_wallet/lib/wallets/wallet/impl/solana_wallet.dart

522 lines
15 KiB
Dart
Raw Normal View History

2024-04-29 16:19:01 +00:00
import 'dart:convert';
2024-03-21 18:57:27 +00:00
import 'dart:io';
2024-03-20 00:50:42 +00:00
import 'dart:math';
2024-04-29 17:01:26 +00:00
import 'package:decimal/decimal.dart';
2024-03-20 00:50:42 +00:00
import 'package:isar/isar.dart';
import 'package:socks5_proxy/socks_client.dart';
2024-03-20 00:50:42 +00:00
import 'package:solana/dto.dart';
import 'package:solana/solana.dart';
2024-05-27 23:56:22 +00:00
import 'package:tuple/tuple.dart';
import '../../../models/balance.dart';
2024-05-27 23:56:22 +00:00
import '../../../models/isar/models/blockchain_data/transaction.dart' as isar;
import '../../../models/isar/models/isar_models.dart';
import '../../../models/node_model.dart';
import '../../../models/paymint/fee_object_model.dart';
import '../../../services/node_service.dart';
import '../../../services/tor_service.dart';
import '../../../utilities/amount/amount.dart';
import '../../../utilities/logger.dart';
2024-06-28 22:05:20 +00:00
import '../../../utilities/prefs.dart';
import '../../crypto_currency/crypto_currency.dart';
import '../../models/tx_data.dart';
import '../intermediate/bip39_wallet.dart';
2024-03-20 00:50:42 +00:00
class SolanaWallet extends Bip39Wallet<Solana> {
SolanaWallet(CryptoCurrencyNetwork network) : super(Solana(network));
2024-05-03 15:33:59 +00:00
static const String _addressDerivationPath = "m/44'/501'/0'/0'";
2024-03-20 00:50:42 +00:00
NodeModel? _solNode;
RpcClient? _rpcClient; // The Solana RpcClient.
2024-03-21 18:57:27 +00:00
2024-03-20 00:50:42 +00:00
Future<Ed25519HDKeyPair> _getKeyPair() async {
2024-05-03 15:33:59 +00:00
return Ed25519HDKeyPair.fromMnemonic(
await getMnemonic(),
account: 0,
change: 0,
);
2024-03-20 00:50:42 +00:00
}
2024-05-03 15:33:59 +00:00
Future<Address> _generateAddress() async {
2024-04-24 15:47:35 +00:00
final addressStruct = Address(
2024-05-03 15:33:59 +00:00
walletId: walletId,
value: (await _getKeyPair()).address,
publicKey: List<int>.empty(),
derivationIndex: 0,
derivationPath: DerivationPath()..value = _addressDerivationPath,
type: info.mainAddressType,
2024-05-03 15:33:59 +00:00
subType: AddressSubType.receiving,
);
2024-03-20 00:50:42 +00:00
return addressStruct;
}
Future<int> _getCurrentBalanceInLamports() async {
_checkClient();
2024-04-24 15:47:35 +00:00
final balance = await _rpcClient?.getBalance((await _getKeyPair()).address);
2024-03-21 18:57:27 +00:00
return balance!.value;
2024-03-20 00:50:42 +00:00
}
2024-04-29 17:01:26 +00:00
2024-04-29 16:19:01 +00:00
Future<int?> _getEstimatedNetworkFee(Amount transferAmount) async {
final latestBlockhash = await _rpcClient?.getLatestBlockhash();
2024-04-29 17:01:26 +00:00
final pubKey = (await _getKeyPair()).publicKey;
2024-04-29 16:19:01 +00:00
2024-05-27 23:56:22 +00:00
final compiledMessage = Message(
instructions: [
SystemInstruction.transfer(
fundingAccount: pubKey,
recipientAccount: pubKey,
lamports: transferAmount.raw.toInt(),
),
],
).compile(
2024-05-03 15:33:59 +00:00
recentBlockhash: latestBlockhash!.value.blockhash,
feePayer: pubKey,
);
2024-04-29 16:19:01 +00:00
return await _rpcClient?.getFeeForMessage(
base64Encode(compiledMessage.toByteArray().toList()),
);
}
2024-03-20 00:50:42 +00:00
@override
FilterOperation? get changeAddressFilterOperation =>
throw UnimplementedError();
@override
Future<void> checkSaveInitialReceivingAddress() async {
try {
2024-05-03 15:33:59 +00:00
Address? address = await getCurrentReceivingAddress();
if (address == null) {
address = await _generateAddress();
await mainDB.updateOrPutAddresses([address]);
}
2024-03-20 00:50:42 +00:00
} catch (e, s) {
Logging.instance.log(
"$runtimeType checkSaveInitialReceivingAddress() failed: $e\n$s",
level: LogLevel.Error,
);
}
}
@override
Future<TxData> prepareSend({required TxData txData}) async {
try {
_checkClient();
2024-03-21 18:57:27 +00:00
2024-03-20 00:50:42 +00:00
if (txData.recipients == null || txData.recipients!.length != 1) {
throw Exception("$runtimeType prepareSend requires 1 recipient");
}
2024-04-24 15:47:35 +00:00
final Amount sendAmount = txData.amount!;
2024-03-20 00:50:42 +00:00
if (sendAmount > info.cachedBalance.spendable) {
throw Exception("Insufficient available balance");
}
2024-04-29 17:19:09 +00:00
final feeAmount = await _getEstimatedNetworkFee(sendAmount);
if (feeAmount == null) {
throw Exception(
2024-05-27 23:56:22 +00:00
"Failed to get fees, please check your node connection.",
);
2024-03-20 00:50:42 +00:00
}
2024-05-03 15:33:59 +00:00
final address = await getCurrentReceivingAddress();
2024-03-20 00:50:42 +00:00
// Rent exemption of Solana
2024-05-03 15:33:59 +00:00
final accInfo = await _rpcClient?.getAccountInfo(address!.value);
2024-04-24 15:47:35 +00:00
final int minimumRent =
await _rpcClient?.getMinimumBalanceForRentExemption(
2024-05-27 23:56:22 +00:00
accInfo!.value!.data.toString().length,
) ??
2024-04-24 15:47:35 +00:00
0; // TODO revisit null condition.
2024-03-21 18:57:27 +00:00
if (minimumRent >
((await _getCurrentBalanceInLamports()) -
txData.amount!.raw.toInt() -
feeAmount)) {
throw Exception(
2024-05-03 15:33:59 +00:00
"Insufficient remaining balance for rent exemption, minimum rent: "
"${minimumRent / pow(10, cryptoCurrency.fractionDigits)}",
);
2024-03-20 00:50:42 +00:00
}
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<TxData> confirmSend({required TxData txData}) async {
try {
_checkClient();
2024-03-21 18:57:27 +00:00
2024-03-20 00:50:42 +00:00
final keyPair = await _getKeyPair();
2024-04-24 15:47:35 +00:00
final recipientAccount = txData.recipients!.first;
final recipientPubKey =
2024-03-21 18:57:27 +00:00
Ed25519HDPublicKey.fromBase58(recipientAccount.address);
2024-03-20 00:50:42 +00:00
final message = Message(
instructions: [
2024-03-21 18:57:27 +00:00
SystemInstruction.transfer(
2024-05-27 23:56:22 +00:00
fundingAccount: keyPair.publicKey,
recipientAccount: recipientPubKey,
lamports: txData.amount!.raw.toInt(),
),
2024-03-21 18:57:27 +00:00
ComputeBudgetInstruction.setComputeUnitPrice(
2024-05-27 23:56:22 +00:00
microLamports: txData.fee!.raw.toInt() - 5000,
),
2024-04-29 21:18:54 +00:00
// 5000 lamports is the base fee for a transaction. This instruction adds the necessary fee on top of base fee if it is needed.
ComputeBudgetInstruction.setComputeUnitLimit(units: 1000000),
// 1000000 is the multiplication number to turn the compute unit price of microLamports to lamports.
// These instructions also help the user to not pay more than the shown fee.
// See: https://solanacookbook.com/references/basic-transactions.html#how-to-change-compute-budget-fee-priority-for-a-transaction
2024-03-20 00:50:42 +00:00
],
);
final txid = await _rpcClient?.signAndSendTransaction(message, [keyPair]);
2024-03-20 00:50:42 +00:00
return txData.copyWith(
txid: txid,
);
} catch (e, s) {
Logging.instance.log(
"$runtimeType Solana confirmSend failed: $e\n$s",
level: LogLevel.Error,
);
rethrow;
}
}
@override
Future<Amount> estimateFeeFor(Amount amount, int feeRate) async {
_checkClient();
2024-03-21 18:57:27 +00:00
2024-03-20 00:50:42 +00:00
if (info.cachedBalance.spendable.raw == BigInt.zero) {
return Amount(
rawValue: BigInt.zero,
fractionDigits: cryptoCurrency.fractionDigits,
);
}
2024-04-29 17:01:26 +00:00
2024-04-29 16:19:01 +00:00
final fee = await _getEstimatedNetworkFee(amount);
if (fee == null) {
throw Exception("Failed to get fees, please check your node connection.");
}
2024-04-29 17:01:26 +00:00
2024-03-20 00:50:42 +00:00
return Amount(
2024-04-29 17:01:26 +00:00
rawValue: BigInt.from(fee),
2024-03-20 00:50:42 +00:00
fractionDigits: cryptoCurrency.fractionDigits,
);
}
@override
Future<FeeObject> get fees async {
_checkClient();
2024-04-29 17:01:26 +00:00
final fee = await _getEstimatedNetworkFee(
Amount.fromDecimal(
Decimal.one, // 1 SOL
fractionDigits: cryptoCurrency.fractionDigits,
),
);
2024-04-29 16:19:01 +00:00
if (fee == null) {
throw Exception("Failed to get fees, please check your node connection.");
}
2024-04-29 17:01:26 +00:00
2024-03-20 00:50:42 +00:00
return FeeObject(
2024-05-27 23:56:22 +00:00
numberOfBlocksFast: 1,
numberOfBlocksAverage: 1,
numberOfBlocksSlow: 1,
fast: fee,
medium: fee,
slow: fee,
);
2024-03-20 00:50:42 +00:00
}
@override
2024-06-28 22:05:20 +00:00
Future<bool> pingCheck() async {
String? health;
2024-03-21 19:38:59 +00:00
try {
_checkClient();
2024-06-28 22:05:20 +00:00
health = await _rpcClient?.getHealth();
return health != null;
2024-03-21 19:38:59 +00:00
} catch (e, s) {
Logging.instance.log(
2024-06-28 22:05:20 +00:00
"$runtimeType Solana pingCheck failed \"health=$health\": $e\n$s",
2024-03-21 19:38:59 +00:00
level: LogLevel.Error,
);
return Future.value(false);
}
2024-03-20 00:50:42 +00:00
}
@override
FilterOperation? get receivingAddressFilterOperation =>
FilterGroup.and(standardReceivingAddressFilters);
@override
Future<void> recover({required bool isRescan}) async {
await refreshMutex.protect(() async {
2024-05-03 15:33:59 +00:00
final addressStruct = await _generateAddress();
2024-03-20 00:50:42 +00:00
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<void> updateBalance() async {
try {
2024-05-03 15:33:59 +00:00
final address = await getCurrentReceivingAddress();
_checkClient();
2024-03-21 18:57:27 +00:00
2024-05-03 15:33:59 +00:00
final balance = await _rpcClient?.getBalance(address!.value);
2024-03-20 00:50:42 +00:00
// Rent exemption of Solana
2024-05-03 15:33:59 +00:00
final accInfo = await _rpcClient?.getAccountInfo(address!.value);
2024-03-21 18:57:27 +00:00
// TODO [prio=low]: handle null account info.
final int minimumRent =
await _rpcClient?.getMinimumBalanceForRentExemption(
2024-05-27 23:56:22 +00:00
accInfo!.value!.data.toString().length,
) ??
2024-03-21 18:57:27 +00:00
0;
// TODO [prio=low]: revisit null condition.
2024-04-24 15:47:35 +00:00
final spendableBalance = balance!.value - minimumRent;
2024-03-20 00:50:42 +00:00
final newBalance = Balance(
total: Amount(
rawValue: BigInt.from(balance.value),
2024-05-15 21:20:45 +00:00
fractionDigits: cryptoCurrency.fractionDigits,
2024-03-20 00:50:42 +00:00
),
spendable: Amount(
rawValue: BigInt.from(spendableBalance),
2024-05-15 21:20:45 +00:00
fractionDigits: cryptoCurrency.fractionDigits,
2024-03-20 00:50:42 +00:00
),
blockedTotal: Amount(
rawValue: BigInt.from(minimumRent),
2024-05-15 21:20:45 +00:00
fractionDigits: cryptoCurrency.fractionDigits,
2024-03-20 00:50:42 +00:00
),
pendingSpendable: Amount(
rawValue: BigInt.zero,
2024-05-15 21:20:45 +00:00
fractionDigits: cryptoCurrency.fractionDigits,
2024-03-20 00:50:42 +00:00
),
);
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<void> updateChainHeight() async {
try {
_checkClient();
2024-03-21 18:57:27 +00:00
2024-04-24 15:47:35 +00:00
final int blockHeight = await _rpcClient?.getSlot() ?? 0;
2024-03-21 18:57:27 +00:00
// TODO [prio=low]: Revisit null condition.
2024-03-20 00:50:42 +00:00
await info.updateCachedChainHeight(
newHeight: blockHeight,
isar: mainDB.isar,
);
} catch (e, s) {
Logging.instance.log(
"Error occurred in solana_wallet.dart while getting"
2024-03-21 18:57:27 +00:00
" chain height for solana: $e\n$s",
2024-03-20 00:50:42 +00:00
level: LogLevel.Error,
);
}
}
@override
Future<void> updateNode() async {
_solNode = getCurrentNode();
await refresh();
}
@override
NodeModel getCurrentNode() {
return _solNode ??
NodeService(secureStorageInterface: secureStorageInterface)
2024-05-15 21:20:45 +00:00
.getPrimaryNodeFor(currency: info.coin) ??
info.coin.defaultNode;
2024-03-20 00:50:42 +00:00
}
@override
Future<void> updateTransactions() async {
try {
_checkClient();
2024-03-21 18:57:27 +00:00
2024-04-24 15:47:35 +00:00
final transactionsList = await _rpcClient?.getTransactionsList(
2024-05-27 23:56:22 +00:00
(await _getKeyPair()).publicKey,
encoding: Encoding.jsonParsed,
);
2024-04-24 15:47:35 +00:00
final txsList =
2024-03-21 18:57:27 +00:00
List<Tuple2<isar.Transaction, Address>>.empty(growable: true);
2024-03-20 00:50:42 +00:00
2024-05-03 15:33:59 +00:00
final myAddress = (await getCurrentReceivingAddress())!;
2024-03-21 18:57:27 +00:00
// TODO [prio=low]: Revisit null assertion below.
for (final tx in transactionsList!) {
2024-04-24 15:47:35 +00:00
final senderAddress =
2024-03-21 18:57:27 +00:00
(tx.transaction as ParsedTransaction).message.accountKeys[0].pubkey;
2024-04-29 21:18:54 +00:00
var receiverAddress =
2024-03-21 18:57:27 +00:00
(tx.transaction as ParsedTransaction).message.accountKeys[1].pubkey;
2024-03-20 00:50:42 +00:00
var txType = isar.TransactionType.unknown;
2024-04-24 15:47:35 +00:00
final txAmount = Amount(
2024-03-21 18:57:27 +00:00
rawValue:
BigInt.from(tx.meta!.postBalances[1] - tx.meta!.preBalances[1]),
2024-03-20 00:50:42 +00:00
fractionDigits: cryptoCurrency.fractionDigits,
);
2024-05-03 15:33:59 +00:00
if ((senderAddress == myAddress.value) &&
(receiverAddress == "11111111111111111111111111111111")) {
// The account that is only 1's are System Program accounts which
// means there is no receiver except the sender,
// see: https://explorer.solana.com/address/11111111111111111111111111111111
2024-03-20 00:50:42 +00:00
txType = isar.TransactionType.sentToSelf;
2024-04-29 21:18:54 +00:00
receiverAddress = senderAddress;
2024-05-03 15:33:59 +00:00
} else if (senderAddress == myAddress.value) {
2024-03-20 00:50:42 +00:00
txType = isar.TransactionType.outgoing;
2024-05-03 15:33:59 +00:00
} else if (receiverAddress == myAddress.value) {
2024-03-20 00:50:42 +00:00
txType = isar.TransactionType.incoming;
}
2024-04-24 15:47:35 +00:00
final transaction = isar.Transaction(
2024-03-20 00:50:42 +00:00
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,
);
2024-04-24 15:47:35 +00:00
final txAddress = Address(
2024-05-03 15:33:59 +00:00
walletId: walletId,
value: receiverAddress,
publicKey: List<int>.empty(),
derivationIndex: 0,
derivationPath: DerivationPath()..value = _addressDerivationPath,
type: AddressType.solana,
subType: txType == isar.TransactionType.outgoing
? AddressSubType.unknown
: AddressSubType.receiving,
);
2024-03-20 00:50:42 +00:00
txsList.add(Tuple2(transaction, txAddress));
}
await mainDB.addNewTransactionData(txsList, walletId);
} catch (e, s) {
Logging.instance.log(
"Error occurred in solana_wallet.dart while getting"
2024-03-21 18:57:27 +00:00
" transactions for solana: $e\n$s",
2024-03-20 00:50:42 +00:00
level: LogLevel.Error,
);
}
}
@override
2024-06-28 22:05:20 +00:00
Future<bool> updateUTXOs() async {
2024-03-20 00:50:42 +00:00
// No UTXOs in Solana
2024-06-28 22:05:20 +00:00
return false;
2024-03-20 00:50:42 +00:00
}
2024-03-21 18:57:27 +00:00
/// Make sure the Solana RpcClient uses Tor if it's enabled.
///
2024-06-28 22:05:20 +00:00
void _checkClient() {
final node = getCurrentNode();
_rpcClient = createRpcClient(
node.host,
node.port,
node.useSSL,
prefs,
TorService.sharedInstance,
);
}
// static helper function for building a sol rpc client
static RpcClient createRpcClient(
final String host,
final int port,
final bool useSSL,
final Prefs prefs,
final TorService torService,
) {
HttpClient? httpClient;
2024-03-21 18:57:27 +00:00
if (prefs.useTor) {
// Make proxied HttpClient.
2024-06-28 22:05:20 +00:00
final proxyInfo = torService.getProxyInfo();
final proxySettings = ProxySettings(proxyInfo.host, proxyInfo.port);
httpClient = HttpClient();
SocksTCPClient.assignToHttpClient(httpClient, [proxySettings]);
}
2024-06-28 22:05:20 +00:00
final regex = RegExp("^(http|https)://");
String editedHost;
if (host.startsWith(regex)) {
editedHost = host.replaceFirst(regex, "");
} else {
editedHost = host;
}
while (editedHost.endsWith("/")) {
editedHost = editedHost.substring(0, editedHost.length - 1);
}
final uri = Uri(
scheme: useSSL ? "https" : "http",
host: editedHost,
port: port,
);
return RpcClient(
uri.toString(),
timeout: const Duration(seconds: 30),
customHeaders: {},
httpClient: httpClient,
);
2024-03-21 18:57:27 +00:00
}
}