CakeWallet/cw_solana/lib/solana_client.dart
David Adegoke fe0c9ecc0e
CW-1084: Solana Issues (#2305)
* fix(solana-issues): Fix missing solana transaction history entries

* fix(solana-issues): Fixes issues relating to Solana Transaction History

This change:
- Modifies transaction parsing logic to handle more scenarios and better parse Solana transaction data.
- Adds partial filter for spam transactions

* fix(solana-issues): Enhance transaction parsing for Associated Token Account (ATA) programs

This change:
- Adds logic to differentiate between create account and token transfer transactions for the ATA program.
- Introduces a check to skip transactions that only create accounts without associated token transfers.

* fix(solana-issues): Improve transaction update logic and enhance error handling

This change:
- Updates the transaction update callback to only trigger when new valid transactions are present.
- Enhances error handling for insufficient funds by distinguishing between errors for sender and receiver.
2025-06-14 04:18:46 +03:00

1009 lines
30 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/node.dart';
import 'package:cw_core/solana_rpc_http_service.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_solana/pending_solana_transaction.dart';
import 'package:cw_solana/solana_balance.dart';
import 'package:cw_solana/solana_exceptions.dart';
import 'package:cw_solana/solana_transaction_model.dart';
import 'package:cw_solana/spl_token.dart';
import 'package:http/http.dart' as http;
import 'package:on_chain/solana/solana.dart';
import 'package:on_chain/solana/src/instructions/associated_token_account/constant.dart';
import 'package:on_chain/solana/src/models/pda/pda.dart';
import 'package:blockchain_utils/blockchain_utils.dart';
import 'package:on_chain/solana/src/rpc/models/models/confirmed_transaction_meta.dart';
import '.secrets.g.dart' as secrets;
class SolanaWalletClient {
final httpClient = http.Client();
SolanaRPC? _provider;
// Minimum amount in SOL to consider a transaction valid (to filter spam)
static const double minValidAmount = 0.00000003;
bool connect(Node node) {
try {
String formattedUrl;
String protocolUsed = node.isSSL ? "https" : "http";
if (node.uriRaw == 'rpc.ankr.com') {
String ankrApiKey = secrets.ankrApiKey;
formattedUrl = '$protocolUsed://${node.uriRaw}/$ankrApiKey';
} else if (node.uriRaw == 'solana-mainnet.core.chainstack.com') {
String chainStackApiKey = secrets.chainStackApiKey;
formattedUrl = '$protocolUsed://${node.uriRaw}/$chainStackApiKey';
} else {
formattedUrl = '$protocolUsed://${node.uriRaw}';
}
_provider = SolanaRPC(SolanaRPCHTTPService(url: formattedUrl));
return true;
} catch (e) {
return false;
}
}
Future<double> getBalance(String walletAddress) async {
try {
final balance = await _provider!.requestWithContext(
SolanaRPCGetBalance(
account: SolAddress(walletAddress),
),
);
final balInLamp = balance.result.toDouble();
final solBalance = balInLamp / SolanaUtils.lamportsPerSol;
return solBalance;
} catch (_) {
return 0.0;
}
}
Future<List<TokenAccountResponse>?> getSPLTokenAccounts(
String mintAddress, String publicKey) async {
try {
final result = await _provider!.request(
SolanaRPCGetTokenAccountsByOwner(
account: SolAddress(publicKey),
mint: SolAddress(mintAddress),
commitment: Commitment.confirmed,
encoding: SolanaRPCEncoding.base64,
),
);
return result;
} catch (e) {
return null;
}
}
Future<SolanaBalance?> getSplTokenBalance(String mintAddress, String walletAddress) async {
// Fetch the token accounts (a token can have multiple accounts for various uses)
final tokenAccounts = await getSPLTokenAccounts(mintAddress, walletAddress);
// Handle scenario where there is no token account
if (tokenAccounts == null || tokenAccounts.isEmpty) {
return null;
}
// Sum the balances of all accounts with the specified mint address
double totalBalance = 0.0;
for (var tokenAccount in tokenAccounts) {
final tokenAmountResult = await _provider!.request(
SolanaRPCGetTokenAccountBalance(account: tokenAccount.pubkey),
);
final balance = tokenAmountResult.uiAmountString;
final balanceAsDouble = double.tryParse(balance ?? '0.0') ?? 0.0;
totalBalance += balanceAsDouble;
}
return SolanaBalance(totalBalance);
}
Future<double> getFeeForMessage(String message, Commitment commitment) async {
try {
final feeForMessage = await _provider!.request(
SolanaRPCGetFeeForMessage(
encodedMessage: message,
commitment: commitment,
),
);
final fee = (feeForMessage?.toDouble() ?? 0.0) / SolanaUtils.lamportsPerSol;
return fee;
} catch (_) {
return 0.0;
}
}
Future<double> getEstimatedFee(SolanaPublicKey publicKey, Commitment commitment) async {
final message = await _getMessageForNativeTransaction(
publicKey: publicKey,
destinationAddress: publicKey.toAddress().address,
lamports: SolanaUtils.lamportsPerSol,
commitment: commitment,
);
final estimatedFee = await _getFeeFromCompiledMessage(
message,
commitment,
);
return estimatedFee;
}
Future<SolanaTransactionModel?> parseTransaction({
VersionedTransactionResponse? txResponse,
required String walletAddress,
String? splTokenSymbol,
}) async {
if (txResponse == null) return null;
try {
final blockTime = txResponse.blockTime;
final meta = txResponse.meta;
final transaction = txResponse.transaction;
if (meta == null || transaction == null) return null;
final int fee = meta.fee;
final feeInSol = fee / SolanaUtils.lamportsPerSol;
final message = transaction.message;
final instructions = message.compiledInstructions;
String signature = (txResponse.transaction?.signatures.isEmpty ?? true)
? ""
: Base58Encoder.encode(txResponse.transaction!.signatures.first);
for (final instruction in instructions) {
final programId = message.accountKeys[instruction.programIdIndex];
if (programId == SystemProgramConst.programId ||
programId == ComputeBudgetConst.programId) {
// For native solana transactions
if (instruction.accounts.length < 2) continue;
// Get the fee payer index based on transaction type
// For legacy transfers, the first account is usually the fee payer
// For versioned, the first account in instruction is usually the fee payer
final feePayerIndex =
txResponse.version == TransactionType.legacy ? 0 : instruction.accounts[0];
final transactionModel = await _parseNativeTransaction(
message: message,
meta: meta,
fee: fee,
feeInSol: feeInSol,
feePayerIndex: feePayerIndex,
walletAddress: walletAddress,
signature: signature,
blockTime: blockTime,
);
if (transactionModel != null) {
return transactionModel;
}
} else if (programId == SPLTokenProgramConst.tokenProgramId) {
// For SPL Token transactions
if (instruction.accounts.length < 2) continue;
final transactionModel = await _parseSPLTokenTransaction(
message: message,
meta: meta,
fee: fee,
feeInSol: feeInSol,
instruction: instruction,
walletAddress: walletAddress,
signature: signature,
blockTime: blockTime,
splTokenSymbol: splTokenSymbol,
);
if (transactionModel != null) {
return transactionModel;
}
} else if (programId == AssociatedTokenAccountProgramConst.associatedTokenProgramId) {
// For ATA program, we need to check if this is a create account transaction
// or if it's part of a normal token transfer
// We skip this transaction if this is the only instruction (this means that it's a create account transaction)
if (instructions.length == 1) {
return null;
}
// We look for a token transfer instruction in the same transaction
bool hasTokenTransfer = false;
for (final otherInstruction in instructions) {
final otherProgramId = message.accountKeys[otherInstruction.programIdIndex];
if (otherProgramId == SPLTokenProgramConst.tokenProgramId) {
hasTokenTransfer = true;
break;
}
}
// If there's no token transfer instruction, it means this is just an ATA creation transaction
if (!hasTokenTransfer) {
return null;
}
continue;
} else {
return null;
}
}
} catch (e, s) {
printV("Error parsing transaction: $e\n$s");
}
return null;
}
Future<SolanaTransactionModel?> _parseNativeTransaction({
required VersionedMessage message,
required ConfirmedTransactionMeta meta,
required int fee,
required double feeInSol,
required int feePayerIndex,
required String walletAddress,
required String signature,
required BigInt? blockTime,
}) async {
// Calculate total balance changes across all accounts
BigInt totalBalanceChange = BigInt.zero;
String? sender;
String? receiver;
for (int i = 0; i < meta.preBalances.length; i++) {
final preBalance = meta.preBalances[i];
final postBalance = meta.postBalances[i];
final balanceChange = preBalance - postBalance;
if (balanceChange > BigInt.zero) {
// This account sent funds
sender = message.accountKeys[i].address;
totalBalanceChange += balanceChange;
} else if (balanceChange < BigInt.zero) {
// This account received funds
receiver = message.accountKeys[i].address;
}
}
// We subtract the fee from total balance change if the fee payer is the sender
if (sender == message.accountKeys[feePayerIndex].address) {
totalBalanceChange -= BigInt.from(fee);
}
if (sender == null || receiver == null) {
return null;
}
final amount = totalBalanceChange / BigInt.from(1e9);
final amountInSol = amount.abs().toDouble();
// Skip transactions with very small amounts (likely spam)
if (amountInSol < minValidAmount) {
return null;
}
return SolanaTransactionModel(
isOutgoingTx: sender == walletAddress,
from: sender,
to: receiver,
id: signature,
amount: amountInSol,
programId: SystemProgramConst.programId.address,
tokenSymbol: 'SOL',
blockTimeInInt: blockTime?.toInt() ?? 0,
fee: feeInSol,
);
}
Future<SolanaTransactionModel?> _parseSPLTokenTransaction({
required VersionedMessage message,
required ConfirmedTransactionMeta meta,
required int fee,
required double feeInSol,
required CompiledInstruction instruction,
required String walletAddress,
required String signature,
required BigInt? blockTime,
String? splTokenSymbol,
}) async {
final preBalances = meta.preTokenBalances;
final postBalances = meta.postTokenBalances;
double amount = 0.0;
bool isOutgoing = false;
String? mintAddress;
double userPreAmount = 0.0;
if (preBalances != null && preBalances.isNotEmpty) {
for (final preBal in preBalances) {
if (preBal.owner?.address == walletAddress) {
userPreAmount = preBal.uiTokenAmount.uiAmount ?? 0.0;
mintAddress = preBal.mint.address;
break;
}
}
}
double userPostAmount = 0.0;
if (postBalances != null && postBalances.isNotEmpty) {
for (final postBal in postBalances) {
if (postBal.owner?.address == walletAddress) {
userPostAmount = postBal.uiTokenAmount.uiAmount ?? 0.0;
mintAddress ??= postBal.mint.address;
break;
}
}
}
final diff = userPreAmount - userPostAmount;
final rawAmount = diff.abs();
final amountInString = rawAmount.toStringAsFixed(6);
amount = double.parse(amountInString);
isOutgoing = diff > 0;
if (mintAddress == null && instruction.accounts.length >= 4) {
final mintIndex = instruction.accounts[3];
mintAddress = message.accountKeys[mintIndex].address;
}
final sender = message.accountKeys[instruction.accounts[0]].address;
final receiver = message.accountKeys[instruction.accounts[1]].address;
String? tokenSymbol = splTokenSymbol;
if (tokenSymbol == null && mintAddress != null) {
final token = await getTokenInfo(mintAddress);
tokenSymbol = token?.symbol;
}
return SolanaTransactionModel(
isOutgoingTx: isOutgoing,
from: sender,
to: receiver,
id: signature,
amount: amount,
programId: SPLTokenProgramConst.tokenProgramId.address,
blockTimeInInt: blockTime?.toInt() ?? 0,
tokenSymbol: tokenSymbol ?? '',
fee: feeInSol,
);
}
/// Load the Address's transactions into the account
Future<List<SolanaTransactionModel>> fetchTransactions(
SolAddress address, {
String? splTokenSymbol,
int? splTokenDecimal,
Commitment? commitment,
SolAddress? walletAddress,
required void Function(List<SolanaTransactionModel>) onUpdate,
}) async {
List<SolanaTransactionModel> transactions = [];
try {
final signatures = await _provider!.request(
SolanaRPCGetSignaturesForAddress(
account: address,
commitment: commitment,
),
);
// The maximum concurrent batch size.
const int batchSize = 10;
for (int i = 0; i < signatures.length; i += batchSize) {
final batch = signatures.skip(i).take(batchSize).toList();
final batchResponses = await Future.wait(batch.map((signature) async {
try {
return await _provider!.request(
SolanaRPCGetTransaction(
transactionSignature: signature['signature'],
encoding: SolanaRPCEncoding.jsonParsed,
maxSupportedTransactionVersion: 0,
),
);
} catch (e) {
// printV("Error fetching transaction: $e");
return null;
}
}));
final versionedBatchResponses = batchResponses.whereType<VersionedTransactionResponse>();
final parsedTransactionsFutures = versionedBatchResponses.map((tx) => parseTransaction(
txResponse: tx,
splTokenSymbol: splTokenSymbol,
walletAddress: walletAddress?.address ?? address.address,
));
final parsedTransactions = await Future.wait(parsedTransactionsFutures);
transactions.addAll(parsedTransactions.whereType<SolanaTransactionModel>().toList());
// Only update UI if we have new valid transactions
if (parsedTransactions.isNotEmpty) {
onUpdate(List<SolanaTransactionModel>.from(transactions));
}
if (i + batchSize < signatures.length) {
await Future.delayed(const Duration(milliseconds: 300));
}
}
return transactions;
} catch (err, s) {
printV('Error fetching transactions: $err \n$s');
return [];
}
}
Future<List<SolanaTransactionModel>> getSPLTokenTransfers({
required String mintAddress,
required String splTokenSymbol,
required int splTokenDecimal,
required SolanaPrivateKey privateKey,
required void Function(List<SolanaTransactionModel>) onUpdate,
}) async {
ProgramDerivedAddress? associatedTokenAccount;
final ownerWalletAddress = privateKey.publicKey().toAddress();
try {
associatedTokenAccount = await _getOrCreateAssociatedTokenAccount(
payerPrivateKey: privateKey,
mintAddress: SolAddress(mintAddress),
ownerAddress: ownerWalletAddress,
shouldCreateATA: false,
);
} catch (e, s) {
printV('$e \n $s');
}
if (associatedTokenAccount == null) return [];
final accountPublicKey = associatedTokenAccount.address;
final tokenTransactions = await fetchTransactions(
accountPublicKey,
splTokenSymbol: splTokenSymbol,
splTokenDecimal: splTokenDecimal,
walletAddress: ownerWalletAddress,
onUpdate: onUpdate,
);
return tokenTransactions;
}
final Map<String, SPLToken?> tokenInfoCache = {};
Future<SPLToken?> getTokenInfo(String mintAddress) async {
if (tokenInfoCache.containsKey(mintAddress)) {
return tokenInfoCache[mintAddress];
} else {
final token = await fetchSPLTokenInfo(mintAddress);
if (token != null) {
tokenInfoCache[mintAddress] = token;
}
return token;
}
}
Future<SPLToken?> fetchSPLTokenInfo(String mintAddress) async {
final programAddress =
MetaplexTokenMetaDataProgramUtils.findMetadataPda(mint: SolAddress(mintAddress));
final token = await _provider!.request(
SolanaRPCGetMetadataAccount(
account: programAddress.address,
commitment: Commitment.confirmed,
),
);
if (token == null) {
return null;
}
final metadata = token.data;
String? iconPath;
//TODO(Further explore fetching images)
// try {
// iconPath = await _client.getIconImageFromTokenUri(metadata.uri);
// } catch (_) {}
String filteredTokenSymbol =
metadata.symbol.replaceFirst(RegExp('^\\\$'), '').replaceAll('\u0000', '');
return SPLToken.fromMetadata(
name: metadata.name,
mint: metadata.symbol,
symbol: filteredTokenSymbol,
mintAddress: token.mint.address,
iconPath: iconPath,
);
}
void stop() {}
SolanaRPC? get getSolanaProvider => _provider;
Future<PendingSolanaTransaction> signSolanaTransaction({
required String tokenTitle,
required int tokenDecimals,
required double inputAmount,
required String destinationAddress,
required SolanaPrivateKey ownerPrivateKey,
required bool isSendAll,
required double solBalance,
String? tokenMint,
List<String> references = const [],
}) async {
const commitment = Commitment.confirmed;
if (tokenTitle == CryptoCurrency.sol.title) {
final pendingNativeTokenTransaction = await _signNativeTokenTransaction(
inputAmount: inputAmount,
destinationAddress: destinationAddress,
ownerPrivateKey: ownerPrivateKey,
commitment: commitment,
isSendAll: isSendAll,
solBalance: solBalance,
);
return pendingNativeTokenTransaction;
} else {
final pendingSPLTokenTransaction = _signSPLTokenTransaction(
tokenDecimals: tokenDecimals,
tokenMint: tokenMint!,
inputAmount: inputAmount,
ownerPrivateKey: ownerPrivateKey,
destinationAddress: destinationAddress,
commitment: commitment,
solBalance: solBalance,
);
return pendingSPLTokenTransaction;
}
}
Future<SolAddress> _getLatestBlockhash(Commitment commitment) async {
final latestBlockhash = await _provider!.request(
const SolanaRPCGetLatestBlockhash(),
);
return latestBlockhash.blockhash;
}
Future<Message> _getMessageForNativeTransaction({
required SolanaPublicKey publicKey,
required String destinationAddress,
required int lamports,
required Commitment commitment,
}) async {
final instructions = [
SystemProgram.transfer(
from: publicKey.toAddress(),
layout: SystemTransferLayout(lamports: BigInt.from(lamports)),
to: SolAddress(destinationAddress),
),
];
final latestBlockhash = await _getLatestBlockhash(commitment);
final message = Message.compile(
transactionInstructions: instructions,
payer: publicKey.toAddress(),
recentBlockhash: latestBlockhash,
);
return message;
}
Future<Message> _getMessageForSPLTokenTransaction({
required SolAddress ownerAddress,
required SolAddress destinationAddress,
required int tokenDecimals,
required SolAddress mintAddress,
required SolAddress sourceAccount,
required int amount,
required Commitment commitment,
}) async {
final instructions = [
SPLTokenProgram.transferChecked(
layout: SPLTokenTransferCheckedLayout(
amount: BigInt.from(amount),
decimals: tokenDecimals,
),
mint: mintAddress,
source: sourceAccount,
destination: destinationAddress,
owner: ownerAddress,
)
];
final latestBlockhash = await _getLatestBlockhash(commitment);
final message = Message.compile(
transactionInstructions: instructions,
payer: ownerAddress,
recentBlockhash: latestBlockhash,
);
return message;
}
Future<double> _getFeeFromCompiledMessage(Message message, Commitment commitment) async {
final base64Message = base64Encode(message.serialize());
final fee = await getFeeForMessage(base64Message, commitment);
return fee;
}
Future<bool> hasSufficientFundsLeftForRent({
required double inputAmount,
required double solBalance,
required double fee,
}) async {
final rent = await _provider!.request(
SolanaRPCGetMinimumBalanceForRentExemption(
size: SolanaTokenAccountUtils.accountSize,
),
);
final rentInSol = (rent.toDouble() / SolanaUtils.lamportsPerSol).toDouble();
final remnant = solBalance - (inputAmount + fee);
if (remnant > rentInSol) return true;
return false;
}
Future<PendingSolanaTransaction> _signNativeTokenTransaction({
required double inputAmount,
required String destinationAddress,
required SolanaPrivateKey ownerPrivateKey,
required Commitment commitment,
required bool isSendAll,
required double solBalance,
}) async {
// Convert SOL to lamport
int lamports = (inputAmount * SolanaUtils.lamportsPerSol).toInt();
Message message = await _getMessageForNativeTransaction(
publicKey: ownerPrivateKey.publicKey(),
destinationAddress: destinationAddress,
lamports: lamports,
commitment: commitment,
);
SolAddress latestBlockhash = await _getLatestBlockhash(commitment);
final fee = await _getFeeFromCompiledMessage(
message,
commitment,
);
bool hasSufficientFundsLeft = await hasSufficientFundsLeftForRent(
inputAmount: inputAmount,
fee: fee,
solBalance: solBalance,
);
if (!hasSufficientFundsLeft) {
throw SolanaSignNativeTokenTransactionRentException();
}
String serializedTransaction;
if (isSendAll) {
final feeInLamports = (fee * SolanaUtils.lamportsPerSol).toInt();
final updatedLamports = lamports - feeInLamports;
final transaction = _constructNativeTransaction(
ownerPrivateKey: ownerPrivateKey,
destinationAddress: destinationAddress,
latestBlockhash: latestBlockhash,
lamports: updatedLamports,
);
serializedTransaction = await _signTransactionInternal(
ownerPrivateKey: ownerPrivateKey,
transaction: transaction,
);
} else {
final transaction = _constructNativeTransaction(
ownerPrivateKey: ownerPrivateKey,
destinationAddress: destinationAddress,
latestBlockhash: latestBlockhash,
lamports: lamports,
);
serializedTransaction = await _signTransactionInternal(
ownerPrivateKey: ownerPrivateKey,
transaction: transaction,
);
}
sendTx() async => await sendTransaction(
serializedTransaction: serializedTransaction,
commitment: commitment,
);
final pendingTransaction = PendingSolanaTransaction(
amount: inputAmount,
serializedTransaction: serializedTransaction,
destinationAddress: destinationAddress,
sendTransaction: sendTx,
fee: fee,
);
return pendingTransaction;
}
SolanaTransaction _constructNativeTransaction({
required SolanaPrivateKey ownerPrivateKey,
required String destinationAddress,
required SolAddress latestBlockhash,
required int lamports,
}) {
final owner = ownerPrivateKey.publicKey().toAddress();
/// Create a transfer instruction to move funds from the owner to the receiver.
final transferInstruction = SystemProgram.transfer(
from: owner,
layout: SystemTransferLayout(lamports: BigInt.from(lamports)),
to: SolAddress(destinationAddress),
);
/// Construct a Solana transaction with the transfer instruction.
return SolanaTransaction(
instructions: [transferInstruction],
recentBlockhash: latestBlockhash,
payerKey: ownerPrivateKey.publicKey().toAddress(),
type: TransactionType.v0,
);
}
Future<ProgramDerivedAddress?> _getOrCreateAssociatedTokenAccount({
required SolanaPrivateKey payerPrivateKey,
required SolAddress ownerAddress,
required SolAddress mintAddress,
required bool shouldCreateATA,
}) async {
final associatedTokenAccount = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
mint: mintAddress,
owner: ownerAddress,
);
SolanaAccountInfo? accountInfo;
try {
accountInfo = await _provider!.request(
SolanaRPCGetAccountInfo(
account: associatedTokenAccount.address,
commitment: Commitment.confirmed,
),
);
} catch (e) {
accountInfo = null;
}
// If account exists, we return the associated token account
if (accountInfo != null) return associatedTokenAccount;
if (!shouldCreateATA) return null;
final payerAddress = payerPrivateKey.publicKey().toAddress();
final createAssociatedTokenAccount = AssociatedTokenAccountProgram.associatedTokenAccount(
payer: payerAddress,
associatedToken: associatedTokenAccount.address,
owner: ownerAddress,
mint: mintAddress,
);
final blockhash = await _getLatestBlockhash(Commitment.confirmed);
final transaction = SolanaTransaction(
payerKey: payerAddress,
instructions: [createAssociatedTokenAccount],
recentBlockhash: blockhash,
type: TransactionType.v0,
);
final serializedTransaction = await _signTransactionInternal(
ownerPrivateKey: payerPrivateKey,
transaction: transaction,
);
await sendTransaction(
serializedTransaction: serializedTransaction,
commitment: Commitment.confirmed,
);
// Wait for confirmation
await Future.delayed(const Duration(seconds: 2));
return associatedTokenAccount;
}
Future<PendingSolanaTransaction> _signSPLTokenTransaction({
required int tokenDecimals,
required String tokenMint,
required double inputAmount,
required String destinationAddress,
required SolanaPrivateKey ownerPrivateKey,
required Commitment commitment,
required double solBalance,
}) async {
final mintAddress = SolAddress(tokenMint);
// Input by the user
final amount = (inputAmount * math.pow(10, tokenDecimals)).toInt();
ProgramDerivedAddress? associatedSenderAccount;
try {
associatedSenderAccount = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
mint: mintAddress,
owner: ownerPrivateKey.publicKey().toAddress(),
);
} catch (e) {
associatedSenderAccount = null;
}
// Throw an appropriate exception if the sender has no associated
// token account
if (associatedSenderAccount == null) {
throw SolanaNoAssociatedTokenAccountException(
ownerPrivateKey.publicKey().toAddress().address,
mintAddress.address,
);
}
ProgramDerivedAddress? associatedRecipientAccount;
try {
associatedRecipientAccount = await _getOrCreateAssociatedTokenAccount(
payerPrivateKey: ownerPrivateKey,
mintAddress: mintAddress,
ownerAddress: SolAddress(destinationAddress),
shouldCreateATA: true,
);
} catch (e) {
associatedRecipientAccount = null;
throw SolanaCreateAssociatedTokenAccountException(e.toString());
}
if (associatedRecipientAccount == null) {
throw SolanaCreateAssociatedTokenAccountException(
'Error fetching recipient associated token account',
);
}
final transferInstructions = SPLTokenProgram.transferChecked(
layout: SPLTokenTransferCheckedLayout(
amount: BigInt.from(amount),
decimals: tokenDecimals,
),
mint: mintAddress,
source: associatedSenderAccount.address,
destination: associatedRecipientAccount.address,
owner: ownerPrivateKey.publicKey().toAddress(),
);
final latestBlockHash = await _getLatestBlockhash(commitment);
final transaction = SolanaTransaction(
payerKey: ownerPrivateKey.publicKey().toAddress(),
instructions: [transferInstructions],
recentBlockhash: latestBlockHash,
);
final message = await _getMessageForSPLTokenTransaction(
ownerAddress: ownerPrivateKey.publicKey().toAddress(),
tokenDecimals: tokenDecimals,
mintAddress: mintAddress,
destinationAddress: associatedRecipientAccount.address,
sourceAccount: associatedSenderAccount.address,
amount: amount,
commitment: commitment,
);
final fee = await _getFeeFromCompiledMessage(message, commitment);
bool hasSufficientFundsLeft = await hasSufficientFundsLeftForRent(
inputAmount: 0,
fee: fee,
solBalance: solBalance,
);
if (!hasSufficientFundsLeft) {
throw SolanaSignSPLTokenTransactionRentException();
}
final serializedTransaction = await _signTransactionInternal(
ownerPrivateKey: ownerPrivateKey,
transaction: transaction,
);
sendTx() async => await sendTransaction(
serializedTransaction: serializedTransaction,
commitment: commitment,
);
final pendingTransaction = PendingSolanaTransaction(
amount: inputAmount,
serializedTransaction: serializedTransaction,
destinationAddress: destinationAddress,
sendTransaction: sendTx,
fee: fee,
);
return pendingTransaction;
}
Future<String> _signTransactionInternal({
required SolanaPrivateKey ownerPrivateKey,
required SolanaTransaction transaction,
}) async {
/// Sign the transaction with the owner's private key.
final ownerSignature = ownerPrivateKey.sign(transaction.serializeMessage());
transaction.addSignature(ownerPrivateKey.publicKey().toAddress(), ownerSignature);
/// Serialize the transaction.
final serializedTransaction = transaction.serializeString();
return serializedTransaction;
}
Future<String> sendTransaction({
required String serializedTransaction,
required Commitment commitment,
}) async {
try {
/// Send the transaction to the Solana network.
final signature = await _provider!.request(
SolanaRPCSendTransaction(
encodedTransaction: serializedTransaction,
commitment: commitment,
),
);
return signature;
} catch (e) {
throw Exception(e);
}
}
Future<String?> getIconImageFromTokenUri(String uri) async {
if (uri.isEmpty || uri == '') return null;
try {
final response = await httpClient.get(Uri.parse(uri));
final jsonResponse = json.decode(response.body) as Map<String, dynamic>;
if (response.statusCode >= 200 && response.statusCode < 300) {
return jsonResponse['image'];
} else {
return null;
}
} catch (e) {
printV('Error occurred while fetching token image: \n${e.toString()}');
return null;
}
}
}