1
+import 'dart:async';
2
+import 'dart:convert';
3
+import 'dart:developer';
4
+import 'dart:io';
5
+import 'package:cw_core/cake_hive.dart';
6
+import 'package:cw_core/crypto_currency.dart';
7
+import 'package:cw_core/node.dart';
8
+import 'package:cw_core/pathForWallet.dart';
9
+import 'package:cw_core/pending_transaction.dart';
10
+import 'package:cw_core/sync_status.dart';
11
+import 'package:cw_core/transaction_direction.dart';
12
+import 'package:cw_core/transaction_priority.dart';
13
+import 'package:cw_core/wallet_addresses.dart';
14
+import 'package:cw_core/wallet_base.dart';
15
+import 'package:cw_core/wallet_info.dart';
16
+import 'package:cw_solana/default_spl_tokens.dart';
17
+import 'package:cw_solana/file.dart';
18
+import 'package:cw_solana/solana_balance.dart';
19
+import 'package:cw_solana/solana_client.dart';
20
+import 'package:cw_solana/solana_exceptions.dart';
21
+import 'package:cw_solana/solana_transaction_credentials.dart';
22
+import 'package:cw_solana/solana_transaction_history.dart';
23
+import 'package:cw_solana/solana_transaction_info.dart';
24
+import 'package:cw_solana/solana_transaction_model.dart';
25
+import 'package:cw_solana/solana_wallet_addresses.dart';
26
+import 'package:cw_solana/spl_token.dart';
27
+import 'package:hex/hex.dart';
28
+import 'package:hive/hive.dart';
29
+import 'package:mobx/mobx.dart';
30
+import 'package:shared_preferences/shared_preferences.dart';
31
+import 'package:solana/metaplex.dart' as metaplex;
32
+import 'package:solana/solana.dart';
33
+import 'package:web3dart/crypto.dart';
34
+
35
+part 'solana_wallet.g.dart';
36
+
37
+class SolanaWallet = SolanaWalletBase with _$SolanaWallet;
38
+
39
+abstract class SolanaWalletBase
40
+ extends WalletBase<SolanaBalance, SolanaTransactionHistory, SolanaTransactionInfo> with Store {
41
+ SolanaWalletBase({
42
+ required WalletInfo walletInfo,
43
+ String? mnemonic,
44
+ String? privateKey,
45
+ required String password,
46
+ SolanaBalance? initialBalance,
47
+ }) : syncStatus = const NotConnectedSyncStatus(),
48
+ _password = password,
49
+ _mnemonic = mnemonic,
50
+ _hexPrivateKey = privateKey,
51
+ _client = SolanaWalletClient(),
52
+ walletAddresses = SolanaWalletAddresses(walletInfo),
53
+ balance = ObservableMap<CryptoCurrency, SolanaBalance>.of(
54
+ {CryptoCurrency.sol: initialBalance ?? SolanaBalance(BigInt.zero.toDouble())}),
55
+ super(walletInfo) {
56
+ this.walletInfo = walletInfo;
57
+ transactionHistory = SolanaTransactionHistory(walletInfo: walletInfo, password: password);
58
+
59
+ if (!CakeHive.isAdapterRegistered(SPLToken.typeId)) {
60
+ CakeHive.registerAdapter(SPLTokenAdapter());
61
+ }
62
+
63
+ _sharedPrefs.complete(SharedPreferences.getInstance());
64
+ }
65
+
66
+ final String _password;
67
+ final String? _mnemonic;
68
+ final String? _hexPrivateKey;
69
+
70
+ // The Solana WalletPair
71
+ Ed25519HDKeyPair? _walletKeyPair;
72
+
73
+ Ed25519HDKeyPair? get walletKeyPair => _walletKeyPair;
74
+
75
+ // To access the privateKey bytes.
76
+ Ed25519HDKeyPairData? _keyPairData;
77
+
78
+ late SolanaWalletClient _client;
79
+
80
+ Timer? _transactionsUpdateTimer;
81
+
82
+ late final Box<SPLToken> splTokensBox;
83
+
84
+ @override
85
+ WalletAddresses walletAddresses;
86
+
87
+ @override
88
+ @observable
89
+ SyncStatus syncStatus;
90
+
91
+ @override
92
+ @observable
93
+ late ObservableMap<CryptoCurrency, SolanaBalance> balance;
94
+
95
+ Completer<SharedPreferences> _sharedPrefs = Completer();
96
+
97
+ @override
98
+ Ed25519HDKeyPairData get keys {
99
+ if (_keyPairData == null) {
100
+ return Ed25519HDKeyPairData([], publicKey: const Ed25519HDPublicKey([]));
101
+ }
102
+
103
+ return _keyPairData!;
104
+ }
105
+
106
+ @override
107
+ String? get seed => _mnemonic;
108
+
109
+ @override
110
+ String get privateKey => HEX.encode(_keyPairData!.bytes);
111
+
112
+ Future<void> init() async {
113
+ final boxName = "${walletInfo.name.replaceAll(" ", "_")}_${SPLToken.boxName}";
114
+
115
+ splTokensBox = await CakeHive.openBox<SPLToken>(boxName);
116
+
117
+ // Create WalletPair using either the mnemonic or the privateKey
118
+ _walletKeyPair = await getWalletPair(
119
+ mnemonic: _mnemonic,
120
+ privateKey: _hexPrivateKey,
121
+ );
122
+
123
+ // Extract the keyPairData containing both the privateKey bytes and the publicKey hex.
124
+ _keyPairData = await _walletKeyPair!.extract();
125
+
126
+ walletInfo.address = _walletKeyPair!.address;
127
+
128
+ await walletAddresses.init();
129
+ await transactionHistory.init();
130
+ await save();
131
+ }
132
+
133
+ Future<Wallet> getWalletPair({String? mnemonic, String? privateKey}) async {
134
+ assert(mnemonic != null || privateKey != null);
135
+
136
+ if (privateKey != null) {
137
+ final privateKeyBytes = hexToBytes(privateKey);
138
+ return await Wallet.fromPrivateKeyBytes(privateKey: privateKeyBytes);
139
+ }
140
+
141
+ return Wallet.fromMnemonic(mnemonic!, account: 0, change: 0);
142
+ }
143
+
144
+ @override
145
+ int calculateEstimatedFee(TransactionPriority priority, int? amount) => 0;
146
+
147
+ @override
148
+ Future<void> changePassword(String password) => throw UnimplementedError("changePassword");
149
+
150
+ @override
151
+ void close() {
152
+ _client.stop();
153
+ _transactionsUpdateTimer?.cancel();
154
+ }
155
+
156
+ @action
157
+ @override
158
+ Future<void> connectToNode({required Node node}) async {
159
+ try {
160
+ syncStatus = ConnectingSyncStatus();
161
+
162
+ final isConnected = _client.connect(node);
163
+
164
+ if (!isConnected) {
165
+ throw Exception("Solana Node connection failed");
166
+ }
167
+
168
+ try {
169
+ await Future.wait([
170
+ _updateBalance(),
171
+ _updateNativeSOLTransactions(),
172
+ _updateSPLTokenTransactions(),
173
+ ]);
174
+ } catch (e) {
175
+ log(e.toString());
176
+ }
177
+
178
+ _setTransactionUpdateTimer();
179
+
180
+ syncStatus = ConnectedSyncStatus();
181
+ } catch (e) {
182
+ syncStatus = FailedSyncStatus();
183
+ }
184
+ }
185
+
186
+ @override
187
+ Future<PendingTransaction> createTransaction(Object credentials) async {
188
+ final solCredentials = credentials as SolanaTransactionCredentials;
189
+
190
+ final outputs = solCredentials.outputs;
191
+
192
+ final hasMultiDestination = outputs.length > 1;
193
+
194
+ await _updateBalance();
195
+
196
+ final CryptoCurrency transactionCurrency =
197
+ balance.keys.firstWhere((element) => element.title == solCredentials.currency.title);
198
+
199
+ final walletBalanceForCurrency = balance[transactionCurrency]!.balance;
200
+
201
+ double totalAmount = 0.0;
202
+
203
+ if (hasMultiDestination) {
204
+ if (outputs.any((item) => item.sendAll || (item.formattedCryptoAmount ?? 0) <= 0)) {
205
+ throw SolanaTransactionWrongBalanceException(transactionCurrency);
206
+ }
207
+
208
+ final totalAmountFromCredentials =
209
+ outputs.fold(0, (acc, value) => acc + (value.formattedCryptoAmount ?? 0));
210
+
211
+ totalAmount = totalAmountFromCredentials.toDouble();
212
+
213
+ if (walletBalanceForCurrency < totalAmount) {
214
+ throw SolanaTransactionWrongBalanceException(transactionCurrency);
215
+ }
216
+ } else {
217
+ final output = outputs.first;
218
+
219
+ final totalOriginalAmount = double.parse(output.cryptoAmount ?? '0.0');
220
+
221
+ totalAmount = output.sendAll ? walletBalanceForCurrency : totalOriginalAmount;
222
+
223
+ if (walletBalanceForCurrency < totalAmount) {
224
+ throw SolanaTransactionWrongBalanceException(transactionCurrency);
225
+ }
226
+ }
227
+
228
+ String? tokenMint;
229
+ // Token Mint is only needed for transactions that are not native tokens(non-SOL transactions)
230
+ if (transactionCurrency.title != CryptoCurrency.sol.title) {
231
+ tokenMint = (transactionCurrency as SPLToken).mintAddress;
232
+ }
233
+
234
+ final pendingSolanaTransaction = await _client.signSolanaTransaction(
235
+ tokenMint: tokenMint,
236
+ tokenTitle: transactionCurrency.title,
237
+ inputAmount: totalAmount,
238
+ ownerKeypair: _walletKeyPair!,
239
+ tokenDecimals: transactionCurrency.decimals,
240
+ destinationAddress: solCredentials.outputs.first.isParsedAddress
241
+ ? solCredentials.outputs.first.extractedAddress!
242
+ : solCredentials.outputs.first.address,
243
+ );
244
+
245
+ return pendingSolanaTransaction;
246
+ }
247
+
248
+ @override
249
+ Future<Map<String, SolanaTransactionInfo>> fetchTransactions() async => {};
250
+
251
+ /// Fetches the native SOL transactions linked to the wallet Public Key
252
+ Future<void> _updateNativeSOLTransactions() async {
253
+ final address = Ed25519HDPublicKey.fromBase58(_walletKeyPair!.address);
254
+
255
+ final transactions = await _client.fetchTransactions(address);
256
+
257
+ final Map<String, SolanaTransactionInfo> result = {};
258
+
259
+ for (var transactionModel in transactions) {
260
+ result[transactionModel.id] = SolanaTransactionInfo(
261
+ id: transactionModel.id,
262
+ to: transactionModel.to,
263
+ from: transactionModel.from,
264
+ blockTime: transactionModel.blockTime,
265
+ direction: transactionModel.isOutgoingTx
266
+ ? TransactionDirection.outgoing
267
+ : TransactionDirection.incoming,
268
+ solAmount: transactionModel.amount,
269
+ isPending: false,
270
+ txFee: transactionModel.fee,
271
+ tokenSymbol: transactionModel.tokenSymbol,
272
+ );
273
+ }
274
+
275
+ transactionHistory.addMany(result);
276
+
277
+ await transactionHistory.save();
278
+ }
279
+
280
+ /// Fetches the SPL Tokens transactions linked to the token account Public Key
281
+ Future<void> _updateSPLTokenTransactions() async {
282
+ List<SolanaTransactionModel> splTokenTransactions = [];
283
+
284
+ for (var token in balance.keys) {
285
+ if (token is SPLToken) {
286
+ final tokenTxs = await _client.getSPLTokenTransfers(
287
+ token.mintAddress,
288
+ token.symbol,
289
+ token.decimal,
290
+ _walletKeyPair!,
291
+ );
292
+
293
+ splTokenTransactions.addAll(tokenTxs);
294
+ }
295
+ }
296
+
297
+ final Map<String, SolanaTransactionInfo> result = {};
298
+
299
+ for (var transactionModel in splTokenTransactions) {
300
+ result[transactionModel.id] = SolanaTransactionInfo(
301
+ id: transactionModel.id,
302
+ to: transactionModel.to,
303
+ from: transactionModel.from,
304
+ blockTime: transactionModel.blockTime,
305
+ direction: transactionModel.isOutgoingTx
306
+ ? TransactionDirection.outgoing
307
+ : TransactionDirection.incoming,
308
+ solAmount: transactionModel.amount,
309
+ isPending: false,
310
+ txFee: transactionModel.fee,
311
+ tokenSymbol: transactionModel.tokenSymbol,
312
+ );
313
+ }
314
+
315
+ transactionHistory.addMany(result);
316
+
317
+ await transactionHistory.save();
318
+ }
319
+
320
+ @override
321
+ Future<void> rescan({required int height}) => throw UnimplementedError("rescan");
322
+
323
+ @override
324
+ Future<void> save() async {
325
+ await walletAddresses.updateAddressesInBox();
326
+ final path = await makePath();
327
+ await write(path: path, password: _password, data: toJSON());
328
+ await transactionHistory.save();
329
+ }
330
+
331
+ @action
332
+ @override
333
+ Future<void> startSync() async {
334
+ try {
335
+ syncStatus = AttemptingSyncStatus();
336
+
337
+ await Future.wait([
338
+ _updateBalance(),
339
+ _updateNativeSOLTransactions(),
340
+ _updateSPLTokenTransactions(),
341
+ ]);
342
+
343
+ syncStatus = SyncedSyncStatus();
344
+ } catch (e) {
345
+ syncStatus = FailedSyncStatus();
346
+ }
347
+ }
348
+
349
+ Future<String> makePath() async => pathForWallet(name: walletInfo.name, type: walletInfo.type);
350
+
351
+ String toJSON() => json.encode({
352
+ 'mnemonic': _mnemonic,
353
+ 'private_key': privateKey,
354
+ 'balance': balance[currency]!.toJSON(),
355
+ });
356
+
357
+ static Future<SolanaWallet> open({
358
+ required String name,
359
+ required String password,
360
+ required WalletInfo walletInfo,
361
+ }) async {
362
+ final path = await pathForWallet(name: name, type: walletInfo.type);
363
+ final jsonSource = await read(path: path, password: password);
364
+ final data = json.decode(jsonSource) as Map;
365
+ final mnemonic = data['mnemonic'] as String?;
366
+ final privateKey = data['private_key'] as String?;
367
+ final balance = SolanaBalance.fromJSON(data['balance'] as String) ?? SolanaBalance(0.0);
368
+
369
+ return SolanaWallet(
370
+ walletInfo: walletInfo,
371
+ password: password,
372
+ mnemonic: mnemonic,
373
+ privateKey: privateKey,
374
+ initialBalance: balance,
375
+ );
376
+ }
377
+
378
+ Future<void> _updateBalance() async {
379
+ balance[currency] = await _fetchSOLBalance();
380
+ await _fetchSPLTokensBalances();
381
+ await save();
382
+ }
383
+
384
+ Future<SolanaBalance> _fetchSOLBalance() async {
385
+ final balance = await _client.getBalance(_walletKeyPair!.address);
386
+
387
+ return SolanaBalance(balance);
388
+ }
389
+
390
+ Future<void> _fetchSPLTokensBalances() async {
391
+ for (var token in splTokensBox.values) {
392
+ if (token.enabled) {
393
+ try {
394
+ final tokenBalance =
395
+ await _client.getSplTokenBalance(token.mintAddress, _walletKeyPair!.address) ??
396
+ balance[token] ??
397
+ SolanaBalance(0.0);
398
+ balance[token] = tokenBalance;
399
+ } catch (e) {
400
+ print('Error fetching spl token (${token.symbol}) balance ${e.toString()}');
401
+ }
402
+ } else {
403
+ balance.remove(token);
404
+ }
405
+ }
406
+ }
407
+
408
+ @override
409
+ Future<void>? updateBalance() async => await _updateBalance();
410
+
411
+ List<SPLToken> get splTokenCurrencies => splTokensBox.values.toList();
412
+
413
+ void addInitialTokens() {
414
+ final initialSPLTokens = DefaultSPLTokens().initialSPLTokens;
415
+
416
+ for (var token in initialSPLTokens) {
417
+ splTokensBox.put(token.mintAddress, token);
418
+ }
419
+ }
420
+
421
+ Future<void> addSPLToken(SPLToken token) async {
422
+ await splTokensBox.put(token.mintAddress, token);
423
+
424
+ if (token.enabled) {
425
+ final tokenBalance =
426
+ await _client.getSplTokenBalance(token.mintAddress, _walletKeyPair!.address) ??
427
+ balance[token] ??
428
+ SolanaBalance(0.0);
429
+
430
+ balance[token] = tokenBalance;
431
+ } else {
432
+ balance.remove(token);
433
+ }
434
+ }
435
+
436
+ Future<void> deleteSPLToken(SPLToken token) async {
437
+ await token.delete();
438
+
439
+ balance.remove(token);
440
+ _updateBalance();
441
+ }
442
+
443
+ Future<SPLToken?> getSPLToken(String mintAddress) async {
444
+ // Convert SPL token mint address to public key
445
+ final mintPublicKey = Ed25519HDPublicKey.fromBase58(mintAddress);
446
+
447
+ // Fetch token's metadata account
448
+ final token = await solanaClient!.rpcClient.getMetadata(mint: mintPublicKey);
449
+
450
+ if (token == null) {
451
+ return null;
452
+ }
453
+
454
+ return SPLToken.fromMetadata(
455
+ name: token.name,
456
+ mint: token.mint,
457
+ symbol: token.symbol,
458
+ mintAddress: mintAddress,
459
+ );
460
+ }
461
+
462
+ @override
463
+ Future<void> renameWalletFiles(String newWalletName) async {
464
+ final currentWalletPath = await pathForWallet(name: walletInfo.name, type: type);
465
+ final currentWalletFile = File(currentWalletPath);
466
+
467
+ final currentDirPath = await pathForWalletDir(name: walletInfo.name, type: type);
468
+ final currentTransactionsFile = File('$currentDirPath/$transactionsHistoryFileName');
469
+
470
+ // Copies current wallet files into new wallet name's dir and files
471
+ if (currentWalletFile.existsSync()) {
472
+ final newWalletPath = await pathForWallet(name: newWalletName, type: type);
473
+ await currentWalletFile.copy(newWalletPath);
474
+ }
475
+ if (currentTransactionsFile.existsSync()) {
476
+ final newDirPath = await pathForWalletDir(name: newWalletName, type: type);
477
+ await currentTransactionsFile.copy('$newDirPath/$transactionsHistoryFileName');
478
+ }
479
+
480
+ // Delete old name's dir and files
481
+ await Directory(currentDirPath).delete(recursive: true);
482
+ }
483
+
484
+ void _setTransactionUpdateTimer() {
485
+ if (_transactionsUpdateTimer?.isActive ?? false) {
486
+ _transactionsUpdateTimer!.cancel();
487
+ }
488
+
489
+ _transactionsUpdateTimer = Timer.periodic(const Duration(seconds: 20), (_) {
490
+ _updateSPLTokenTransactions();
491
+ _updateNativeSOLTransactions();
492
+ _updateBalance();
493
+ });
494
+ }
495
+
496
+ Future<String> signSolanaMessage(String message) async {
497
+ // Convert the message to bytes
498
+ final messageBytes = utf8.encode(message);
499
+
500
+ // Sign the message bytes with the wallet's private key
501
+ final signature = await _walletKeyPair!.sign(messageBytes);
502
+
503
+ // Convert the signature to a hexadecimal string
504
+ final hex = bytesToHex(signature.bytes);
505
+
506
+ return hex;
507
+ }
508
+
509
+ SolanaClient? get solanaClient => _client.getSolanaClient;
510
+}