13
import 'package:cw_solana/spl_token.dart';
14
import 'package:http/http.dart' as http;
15
import 'package:on_chain/solana/solana.dart';
16
+import 'package:on_chain/solana/src/instructions/associated_token_account/constant.dart';
17
import 'package:on_chain/solana/src/models/pda/pda.dart';
18
import 'package:blockchain_utils/blockchain_utils.dart';
19
+import 'package:on_chain/solana/src/rpc/models/models/confirmed_transaction_meta.dart';
20
import '.secrets.g.dart' as secrets;
21
22
class SolanaWalletClient {
23
final httpClient = http.Client();
24
SolanaRPC? _provider;
25
+ // Minimum amount in SOL to consider a transaction valid (to filter spam)
26
+ static const double minValidAmount = 0.00000003;
27
28
bool connect(Node node) {
29
try {
159
if (meta == null || transaction == null) return null;
160
161
final int fee = meta.fee;
162
+ final feeInSol = fee / SolanaUtils.lamportsPerSol;
163
164
final message = transaction.message;
165
final instructions = message.compiledInstructions;
166
162
- String sender = "";
163
- String receiver = "";
164
-
167
String signature = (txResponse.transaction?.signatures.isEmpty ?? true)
168
? ""
169
: Base58Encoder.encode(txResponse.transaction!.signatures.first);
170
171
+
172
for (final instruction in instructions) {
173
final programId = message.accountKeys[instruction.programIdIndex];
174
172
- if (programId == SystemProgramConst.programId) {
175
+ if (programId == SystemProgramConst.programId ||
176
+ programId == ComputeBudgetConst.programId) {
177
// For native solana transactions
178
+ if (instruction.accounts.length < 2) continue;
179
175
- if (txResponse.version == TransactionType.legacy) {
176
- // For legacy transfers, the fee payer (index 0) is the sender.
177
- sender = message.accountKeys[0].address;
178
-
179
- final senderPreBalance = meta.preBalances[0];
180
- final senderPostBalance = meta.postBalances[0];
181
- final feeForTx = fee / SolanaUtils.lamportsPerSol;
182
-
183
- // The loss on the sender's account would include both the transfer amount and the fee.
184
- // So we would subtract the fee to calculate the actual amount that was transferred (in lamports).
185
- final transferLamports = (senderPreBalance - senderPostBalance) - BigInt.from(fee);
186
-
187
- // Next, we attempt to find the receiver by comparing the balance changes.
188
- // (The index 0 is for the sender so we skip it.)
189
- bool foundReceiver = false;
190
- for (int i = 1; i < meta.preBalances.length; i++) {
191
- // The increase in balance on the receiver account should correspond to the transfer amount we calculated earlieer.
192
- final pre = meta.preBalances[i];
193
- final post = meta.postBalances[i];
194
- if ((post - pre) == transferLamports) {
195
- receiver = message.accountKeys[i].address;
196
- foundReceiver = true;
197
- break;
198
- }
199
- }
200
-
201
- if (!foundReceiver) {
202
- // Optionally (and rarely), if no account shows the exact expected change,
203
- // we set the receiver address to unknown.
204
- receiver = "unknown";
205
- }
180
+ // Get the fee payer index based on transaction type
181
+ // For legacy transfers, the first account is usually the fee payer
182
+ // For versioned, the first account in instruction is usually the fee payer
183
+ final feePayerIndex =
184
+ txResponse.version == TransactionType.legacy ? 0 : instruction.accounts[0];
185
+
186
+ final transactionModel = await _parseNativeTransaction(
187
+ message: message,
188
+ meta: meta,
189
+ fee: fee,
190
+ feeInSol: feeInSol,
191
+ feePayerIndex: feePayerIndex,
192
+ walletAddress: walletAddress,
193
+ signature: signature,
194
+ blockTime: blockTime,
195
+ );
196
207
- final amount = transferLamports / BigInt.from(1e9);
208
-
209
- return SolanaTransactionModel(
210
- isOutgoingTx: sender == walletAddress,
211
- from: sender,
212
- to: receiver,
213
- id: signature,
214
- amount: amount.abs(),
215
- programId: SystemProgramConst.programId.address,
216
- tokenSymbol: 'SOL',
217
- blockTimeInInt: blockTime?.toInt() ?? 0,
218
- fee: feeForTx,
219
- );
220
- } else {
221
- if (instruction.accounts.length < 2) continue;
222
- final senderIndex = instruction.accounts[0];
223
- final receiverIndex = instruction.accounts[1];
224
-
225
- sender = message.accountKeys[senderIndex].address;
226
- receiver = message.accountKeys[receiverIndex].address;
227
-
228
- final feeForTx = fee / SolanaUtils.lamportsPerSol;
229
-
230
- final preBalances = meta.preBalances;
231
- final postBalances = meta.postBalances;
232
-
233
- final amountInString =
234
- (((preBalances[senderIndex] - postBalances[senderIndex]) / BigInt.from(1e9))
235
- .toDouble() -
236
- feeForTx)
237
- .toStringAsFixed(6);
238
-
239
- final amount = double.parse(amountInString);
240
-
241
- return SolanaTransactionModel(
242
- isOutgoingTx: sender == walletAddress,
243
- from: sender,
244
- to: receiver,
245
- id: signature,
246
- amount: amount.abs(),
247
- programId: SystemProgramConst.programId.address,
248
- tokenSymbol: 'SOL',
249
- blockTimeInInt: blockTime?.toInt() ?? 0,
250
- fee: feeForTx,
251
- );
197
+ if (transactionModel != null) {
198
+ return transactionModel;
199
}
200
} else if (programId == SPLTokenProgramConst.tokenProgramId) {
201
// For SPL Token transactions
202
if (instruction.accounts.length < 2) continue;
203
257
- final preBalances = meta.preTokenBalances;
258
- final postBalances = meta.postTokenBalances;
204
+ final transactionModel = await _parseSPLTokenTransaction(
205
+ message: message,
206
+ meta: meta,
207
+ fee: fee,
208
+ feeInSol: feeInSol,
209
+ instruction: instruction,
210
+ walletAddress: walletAddress,
211
+ signature: signature,
212
+ blockTime: blockTime,
213
+ splTokenSymbol: splTokenSymbol,
214
+ );
215
260
- double amount = 0.0;
261
- bool isOutgoing = false;
262
- String? mintAddress;
216
+ if (transactionModel != null) {
217
+ return transactionModel;
218
+ }
219
+ } else if (programId == AssociatedTokenAccountProgramConst.associatedTokenProgramId) {
220
+ // For ATA program, we need to check if this is a create account transaction
221
+ // or if it's part of a normal token transfer
222
264
- double userPreAmount = 0.0;
265
- if (preBalances != null && preBalances.isNotEmpty) {
266
- for (final preBal in preBalances) {
267
- if (preBal.owner?.address == walletAddress) {
268
- userPreAmount = preBal.uiTokenAmount.uiAmount ?? 0.0;
223
+ // We skip this transaction if this is the only instruction (this means that it's a create account transaction)
224
+ if (instructions.length == 1) {
225
+ return null;
226
+ }
227
270
- mintAddress = preBal.mint.address;
271
- break;
272
- }
228
+ // We look for a token transfer instruction in the same transaction
229
+ bool hasTokenTransfer = false;
230
+ for (final otherInstruction in instructions) {
231
+ final otherProgramId = message.accountKeys[otherInstruction.programIdIndex];
232
+ if (otherProgramId == SPLTokenProgramConst.tokenProgramId) {
233
+ hasTokenTransfer = true;
234
+ break;
235
}
236
}
237
276
- double userPostAmount = 0.0;
277
- if (postBalances != null && postBalances.isNotEmpty) {
278
- for (final postBal in postBalances) {
279
- if (postBal.owner?.address == walletAddress) {
280
- userPostAmount = postBal.uiTokenAmount.uiAmount ?? 0.0;
281
-
282
- mintAddress ??= postBal.mint.address;
283
- break;
284
- }
285
- }
238
+ // If there's no token transfer instruction, it means this is just an ATA creation transaction
239
+ if (!hasTokenTransfer) {
240
+ return null;
241
}
242
288
- final diff = userPreAmount - userPostAmount;
289
- final rawAmount = diff.abs();
243
+ continue;
244
+ } else {
245
+ return null;
246
+ }
247
+ }
248
+ } catch (e, s) {
249
+ printV("Error parsing transaction: $e\n$s");
250
+ }
251
291
- final amountInString = rawAmount.toStringAsFixed(6);
292
- amount = double.parse(amountInString);
252
+ return null;
253
+ }
254
294
- isOutgoing = diff > 0;
255
+ Future<SolanaTransactionModel?> _parseNativeTransaction({
256
+ required VersionedMessage message,
257
+ required ConfirmedTransactionMeta meta,
258
+ required int fee,
259
+ required double feeInSol,
260
+ required int feePayerIndex,
261
+ required String walletAddress,
262
+ required String signature,
263
+ required BigInt? blockTime,
264
+ }) async {
265
+ // Calculate total balance changes across all accounts
266
+ BigInt totalBalanceChange = BigInt.zero;
267
+ String? sender;
268
+ String? receiver;
269
+
270
+ for (int i = 0; i < meta.preBalances.length; i++) {
271
+ final preBalance = meta.preBalances[i];
272
+ final postBalance = meta.postBalances[i];
273
+ final balanceChange = preBalance - postBalance;
274
+
275
+ if (balanceChange > BigInt.zero) {
276
+ // This account sent funds
277
+ sender = message.accountKeys[i].address;
278
+ totalBalanceChange += balanceChange;
279
+ } else if (balanceChange < BigInt.zero) {
280
+ // This account received funds
281
+ receiver = message.accountKeys[i].address;
282
+ }
283
+ }
284
296
- if (mintAddress == null && instruction.accounts.length >= 4) {
297
- final mintIndex = instruction.accounts[3];
298
- mintAddress = message.accountKeys[mintIndex].address;
299
- }
285
+ // We subtract the fee from total balance change if the fee payer is the sender
286
+ if (sender == message.accountKeys[feePayerIndex].address) {
287
+ totalBalanceChange -= BigInt.from(fee);
288
+ }
289
301
- final sender = message.accountKeys[instruction.accounts[0]].address;
302
- final receiver = message.accountKeys[instruction.accounts[1]].address;
290
+ if (sender == null || receiver == null) {
291
+ return null;
292
+ }
293
304
- String? tokenSymbol = splTokenSymbol;
294
+ final amount = totalBalanceChange / BigInt.from(1e9);
295
+ final amountInSol = amount.abs().toDouble();
296
306
- if (tokenSymbol == null && mintAddress != null) {
307
- final token = await getTokenInfo(mintAddress);
308
- tokenSymbol = token?.symbol;
309
- }
297
+ // Skip transactions with very small amounts (likely spam)
298
+ if (amountInSol < minValidAmount) {
299
+ return null;
300
+ }
301
311
- return SolanaTransactionModel(
312
- isOutgoingTx: isOutgoing,
313
- from: sender,
314
- to: receiver,
315
- id: signature,
316
- amount: amount,
317
- programId: SPLTokenProgramConst.tokenProgramId.address,
318
- blockTimeInInt: blockTime?.toInt() ?? 0,
319
- tokenSymbol: tokenSymbol ?? '',
320
- fee: fee / SolanaUtils.lamportsPerSol,
321
- );
322
- } else {
323
- return null;
302
+ return SolanaTransactionModel(
303
+ isOutgoingTx: sender == walletAddress,
304
+ from: sender,
305
+ to: receiver,
306
+ id: signature,
307
+ amount: amountInSol,
308
+ programId: SystemProgramConst.programId.address,
309
+ tokenSymbol: 'SOL',
310
+ blockTimeInInt: blockTime?.toInt() ?? 0,
311
+ fee: feeInSol,
312
+ );
313
+ }
314
+
315
+ Future<SolanaTransactionModel?> _parseSPLTokenTransaction({
316
+ required VersionedMessage message,
317
+ required ConfirmedTransactionMeta meta,
318
+ required int fee,
319
+ required double feeInSol,
320
+ required CompiledInstruction instruction,
321
+ required String walletAddress,
322
+ required String signature,
323
+ required BigInt? blockTime,
324
+ String? splTokenSymbol,
325
+ }) async {
326
+ final preBalances = meta.preTokenBalances;
327
+ final postBalances = meta.postTokenBalances;
328
+
329
+ double amount = 0.0;
330
+ bool isOutgoing = false;
331
+ String? mintAddress;
332
+
333
+ double userPreAmount = 0.0;
334
+ if (preBalances != null && preBalances.isNotEmpty) {
335
+ for (final preBal in preBalances) {
336
+ if (preBal.owner?.address == walletAddress) {
337
+ userPreAmount = preBal.uiTokenAmount.uiAmount ?? 0.0;
338
+
339
+ mintAddress = preBal.mint.address;
340
+ break;
341
}
342
}
326
- } catch (e, s) {
327
- printV("Error parsing transaction: $e\n$s");
343
}
344
330
- return null;
345
+ double userPostAmount = 0.0;
346
+ if (postBalances != null && postBalances.isNotEmpty) {
347
+ for (final postBal in postBalances) {
348
+ if (postBal.owner?.address == walletAddress) {
349
+ userPostAmount = postBal.uiTokenAmount.uiAmount ?? 0.0;
350
+
351
+ mintAddress ??= postBal.mint.address;
352
+ break;
353
+ }
354
+ }
355
+ }
356
+
357
+ final diff = userPreAmount - userPostAmount;
358
+ final rawAmount = diff.abs();
359
+
360
+ final amountInString = rawAmount.toStringAsFixed(6);
361
+ amount = double.parse(amountInString);
362
+
363
+ isOutgoing = diff > 0;
364
+
365
+ if (mintAddress == null && instruction.accounts.length >= 4) {
366
+ final mintIndex = instruction.accounts[3];
367
+ mintAddress = message.accountKeys[mintIndex].address;
368
+ }
369
+
370
+ final sender = message.accountKeys[instruction.accounts[0]].address;
371
+ final receiver = message.accountKeys[instruction.accounts[1]].address;
372
+
373
+ String? tokenSymbol = splTokenSymbol;
374
+
375
+ if (tokenSymbol == null && mintAddress != null) {
376
+ final token = await getTokenInfo(mintAddress);
377
+ tokenSymbol = token?.symbol;
378
+ }
379
+
380
+ return SolanaTransactionModel(
381
+ isOutgoingTx: isOutgoing,
382
+ from: sender,
383
+ to: receiver,
384
+ id: signature,
385
+ amount: amount,
386
+ programId: SPLTokenProgramConst.tokenProgramId.address,
387
+ blockTimeInInt: blockTime?.toInt() ?? 0,
388
+ tokenSymbol: tokenSymbol ?? '',
389
+ fee: feeInSol,
390
+ );
391
}
392
393
/// Load the Address's transactions into the account
441
442
transactions.addAll(parsedTransactions.whereType<SolanaTransactionModel>().toList());
443
384
- // Calling the callback after each batch is processed, therefore passing the current list of transactions.
385
- onUpdate(List<SolanaTransactionModel>.from(transactions));
444
+ // Only update UI if we have new valid transactions
445
+ if (parsedTransactions.isNotEmpty) {
446
+ onUpdate(List<SolanaTransactionModel>.from(transactions));
447
+ }
448
449
if (i + batchSize < signatures.length) {
388
- await Future.delayed(const Duration(milliseconds: 500));
450
+ await Future.delayed(const Duration(milliseconds: 300));
451
}
452
}
453
794
SolanaAccountInfo? accountInfo;
795
try {
796
accountInfo = await _provider!.request(
735
- SolanaRPCGetAccountInfo(account: associatedTokenAccount.address),
797
+ SolanaRPCGetAccountInfo(
798
+ account: associatedTokenAccount.address,
799
+ commitment: Commitment.confirmed,
800
+ ),
801
);
802
} catch (e) {
803
accountInfo = null;
804
}
805
741
- // If aacountInfo is null, signifies that the associatedTokenAccount has only been created locally and not been broadcasted to the blockchain.
806
+ // If account exists, we return the associated token account
807
if (accountInfo != null) return associatedTokenAccount;
808
809
if (!shouldCreateATA) return null;
810
811
+ final payerAddress = payerPrivateKey.publicKey().toAddress();
812
+
813
final createAssociatedTokenAccount = AssociatedTokenAccountProgram.associatedTokenAccount(
747
- payer: payerPrivateKey.publicKey().toAddress(),
814
+ payer: payerAddress,
815
associatedToken: associatedTokenAccount.address,
816
owner: ownerAddress,
817
mint: mintAddress,
820
final blockhash = await _getLatestBlockhash(Commitment.confirmed);
821
822
final transaction = SolanaTransaction(
756
- payerKey: payerPrivateKey.publicKey().toAddress(),
823
+ payerKey: payerAddress,
824
instructions: [createAssociatedTokenAccount],
825
recentBlockhash: blockhash,
826
+ type: TransactionType.v0,
827
);
828
761
- transaction.sign([payerPrivateKey]);
829
+ final serializedTransaction = await _signTransactionInternal(
830
+ ownerPrivateKey: payerPrivateKey,
831
+ transaction: transaction,
832
+ );
833
834
await sendTransaction(
764
- serializedTransaction: transaction.serializeString(),
835
+ serializedTransaction: serializedTransaction,
836
commitment: Commitment.confirmed,
837
);
838
768
- // Delay for propagation on the blockchain for newly created associated token addresses
839
+ // Wait for confirmation
840
await Future.delayed(const Duration(seconds: 2));
841
842
return associatedTokenAccount;
961
}) async {
962
/// Sign the transaction with the owner's private key.
963
final ownerSignature = ownerPrivateKey.sign(transaction.serializeMessage());
893
-
964
+
965
transaction.addSignature(ownerPrivateKey.publicKey().toAddress(), ownerSignature);
966
967
/// Serialize the transaction.