feat: Enhance Solana wallet with token program ID support (#2814)
- Handle custom token program IDs, supporting both standard SPL Token and Token-2022. - Fetch the appropriate token program ID based on mint address. - Updated associated token account creation logic to use the detected token program ID, ensuring compatibility with different token standards.
David Adegoke committed
Jan 20, 2026 at 14:22 UTC
02225fab5d1250d9b7a4675dfb03127ec170b220
2 files changed
+238
-25
cw_evm/lib/evm_chain_transaction_history.dart
-1
@@ -3,7 +3,6 @@ import 'dart:core';
3
import 'dart:developer';
4
import 'package:cw_core/encryption_file_utils.dart';
5
import 'package:cw_core/pathForWallet.dart';
6
-import 'package:cw_core/utils/print_verbose.dart';
6
import 'package:cw_core/wallet_info.dart';
7
import 'package:cw_evm/evm_chain_transaction_info.dart';
8
import 'package:cw_evm/utils/evm_chain_utils.dart';
cw_solana/lib/solana_client.dart
+238
-24
@@ -1183,6 +1183,7 @@ class SolanaWalletClient {
1183
required SolAddress sourceAccount,
1184
required int amount,
1185
required Commitment commitment,
1186
+ required SolAddress tokenProgramId,
1187
}) async {
1188
final instructions = [
1189
SPLTokenProgram.transferChecked(
@@ -1340,15 +1341,110 @@ class SolanaWalletClient {
1341
);
1342
}
1343
1344
+ /// Creates a transferChecked instruction with a custom token program ID.
1345
+ /// This supports both standard SPL Token and Token-2022.
1346
+ TransactionInstruction _createTransferCheckedInstruction({
1347
+ required SolAddress tokenProgramId,
1348
+ required SolAddress source,
1349
+ required SolAddress destination,
1350
+ required SolAddress mint,
1351
+ required SolAddress owner,
1352
+ required BigInt amount,
1353
+ required int decimals,
1354
+ }) {
1355
+ // TransferChecked instruction format:
1356
+ // - Instruction discriminator: 12 (u8)
1357
+ // - Amount: 8 bytes (u64, little-endian)
1358
+ // - Decimals: 1 byte (u8)
1359
+
1360
+ // Convert BigInt to 8-byte little-endian array
1361
+ final amountBytes = <int>[];
1362
+ var amountValue = amount.toUnsigned(64);
1363
+ for (int i = 0; i < 8; i++) {
1364
+ amountBytes.add((amountValue & BigInt.from(0xFF)).toInt());
1365
+ amountValue = amountValue >> 8;
1366
+ }
1367
+
1368
+ final instructionData = <int>[12, ...amountBytes, decimals];
1369
+
1370
+ // Account order for transferChecked:
1371
+ // 0. source (writable)
1372
+ // 1. mint (readonly)
1373
+ // 2. destination (writable)
1374
+ // 3. owner (signer)
1375
+ final accounts = [
1376
+ AccountMeta(
1377
+ publicKey: source,
1378
+ isWritable: true,
1379
+ isSigner: false,
1380
+ ),
1381
+ AccountMeta(
1382
+ publicKey: mint,
1383
+ isWritable: false,
1384
+ isSigner: false,
1385
+ ),
1386
+ AccountMeta(
1387
+ publicKey: destination,
1388
+ isWritable: true,
1389
+ isSigner: false,
1390
+ ),
1391
+ AccountMeta(
1392
+ publicKey: owner,
1393
+ isWritable: false,
1394
+ isSigner: true,
1395
+ ),
1396
+ ];
1397
+
1398
+ return TransactionInstruction.fromBytes(
1399
+ programId: tokenProgramId,
1400
+ instructionBytes: instructionData,
1401
+ keys: accounts,
1402
+ );
1403
+ }
1404
+
1405
+ /// Gets the token program ID for a given mint address.
1406
+ /// Returns the standard SPL Token program ID if the mint account cannot be fetched.
1407
+ Future<SolAddress> _getTokenProgramId(SolAddress mintAddress) async {
1408
+ try {
1409
+ final mintAccountInfo = await _provider!.request(
1410
+ SolanaRPCGetAccountInfo(
1411
+ account: mintAddress,
1412
+ commitment: Commitment.confirmed,
1413
+ ),
1414
+ );
1415
+
1416
+ // Determine the token program ID from the mint account owner
1417
+ if (mintAccountInfo != null) {
1418
+ return mintAccountInfo.owner;
1419
+ }
1420
+ } catch (e) {
1421
+ // If we can't fetch mint info, default to standard SPL Token program
1422
+ printV('Warning: Could not fetch mint account info: $e');
1423
+ }
1424
+
1425
+ return SPLTokenProgramConst.tokenProgramId;
1426
+ }
1427
+
1428
Future<ProgramDerivedAddress?> _getOrCreateAssociatedTokenAccount({
1429
required SolanaPrivateKey payerPrivateKey,
1430
required SolAddress ownerAddress,
1431
required SolAddress mintAddress,
1432
required bool shouldCreateATA,
1433
}) async {
1349
- final associatedTokenAccount = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
1434
+ // For transaction history loading (shouldCreateATA: false), try standard token program first
1435
+ // to avoid unnecessary RPC call. Only fetch token program ID when creating accounts.
1436
+ SolAddress tokenProgramId = SPLTokenProgramConst.tokenProgramId;
1437
+
1438
+ if (shouldCreateATA) {
1439
+ // Only fetch token program ID when we need to create an account
1440
+ tokenProgramId = await _getTokenProgramId(mintAddress);
1441
+ }
1442
+
1443
+ // Try with standard token program first (most common case)
1444
+ var associatedTokenAccount = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
1445
mint: mintAddress,
1446
owner: ownerAddress,
1447
+ tokenProgramId: SPLTokenProgramConst.tokenProgramId,
1448
);
1449
1450
SolanaAccountInfo? accountInfo;
@@ -1363,10 +1459,40 @@ class SolanaWalletClient {
1459
accountInfo = null;
1460
}
1461
1366
- // If account exists, we return the associated token account
1462
+ // If account exists with standard program, return it
1463
if (accountInfo != null) return associatedTokenAccount;
1464
1369
- if (!shouldCreateATA) return null;
1465
+ // If not found and we're not creating, try Token-2022 as fallback
1466
+ if (!shouldCreateATA) {
1467
+ try {
1468
+ final token2022ProgramId = await _getTokenProgramId(mintAddress);
1469
+ if (token2022ProgramId.address != SPLTokenProgramConst.tokenProgramId.address) {
1470
+ associatedTokenAccount = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
1471
+ mint: mintAddress,
1472
+ owner: ownerAddress,
1473
+ tokenProgramId: token2022ProgramId,
1474
+ );
1475
+
1476
+ try {
1477
+ accountInfo = await _provider!.request(
1478
+ SolanaRPCGetAccountInfo(
1479
+ account: associatedTokenAccount.address,
1480
+ commitment: Commitment.confirmed,
1481
+ ),
1482
+ );
1483
+ if (accountInfo != null) return associatedTokenAccount;
1484
+ } catch (_) {}
1485
+ }
1486
+ } catch (_) {}
1487
+ return null;
1488
+ }
1489
+
1490
+ // For account creation, use the detected token program ID
1491
+ associatedTokenAccount = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
1492
+ mint: mintAddress,
1493
+ owner: ownerAddress,
1494
+ tokenProgramId: tokenProgramId,
1495
+ );
1496
1497
final payerAddress = payerPrivateKey.publicKey().toAddress();
1498
@@ -1375,6 +1501,7 @@ class SolanaWalletClient {
1501
associatedToken: associatedTokenAccount.address,
1502
owner: ownerAddress,
1503
mint: mintAddress,
1504
+ tokenProgramId: tokenProgramId,
1505
);
1506
1507
final blockhash = await _getLatestBlockhash(Commitment.confirmed);
@@ -1415,18 +1542,63 @@ class SolanaWalletClient {
1542
1543
// Input by the user
1544
final amount = (inputAmount * math.pow(10, tokenDecimals)).toInt();
1545
+
1546
+ final tokenProgramId = await _getTokenProgramId(mintAddress);
1547
+
1548
ProgramDerivedAddress? associatedSenderAccount;
1549
+ SolAddress senderTokenProgramId = tokenProgramId;
1550
+
1551
try {
1552
associatedSenderAccount = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
1553
mint: mintAddress,
1554
owner: ownerPrivateKey.publicKey().toAddress(),
1555
+ tokenProgramId: tokenProgramId,
1556
);
1557
+
1558
+ // Verify the account exists and get the actual program ID that owns it
1559
+ final accountInfo = await _provider!.request(
1560
+ SolanaRPCGetAccountInfo(
1561
+ account: associatedSenderAccount.address,
1562
+ commitment: Commitment.confirmed,
1563
+ ),
1564
+ );
1565
+
1566
+ if (accountInfo != null) {
1567
+ senderTokenProgramId = accountInfo.owner;
1568
+ } else {
1569
+ associatedSenderAccount = null;
1570
+ }
1571
} catch (e) {
1572
associatedSenderAccount = null;
1573
}
1574
1428
- // Throw an appropriate exception if the sender has no associated
1429
- // token account
1575
+ // If account doesn't exist with detected program ID, try standard token program as fallback
1576
+ if (associatedSenderAccount == null &&
1577
+ tokenProgramId.address != SPLTokenProgramConst.tokenProgramId.address) {
1578
+ try {
1579
+ associatedSenderAccount = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
1580
+ mint: mintAddress,
1581
+ owner: ownerPrivateKey.publicKey().toAddress(),
1582
+ tokenProgramId: SPLTokenProgramConst.tokenProgramId,
1583
+ );
1584
+
1585
+ final accountInfo = await _provider!.request(
1586
+ SolanaRPCGetAccountInfo(
1587
+ account: associatedSenderAccount.address,
1588
+ commitment: Commitment.confirmed,
1589
+ ),
1590
+ );
1591
+
1592
+ if (accountInfo != null) {
1593
+ senderTokenProgramId = accountInfo.owner;
1594
+ } else {
1595
+ associatedSenderAccount = null;
1596
+ }
1597
+ } catch (_) {
1598
+ associatedSenderAccount = null;
1599
+ }
1600
+ }
1601
+
1602
if (associatedSenderAccount == null) {
1603
throw SolanaNoAssociatedTokenAccountException(
1604
ownerPrivateKey.publicKey().toAddress().address,
@@ -1434,35 +1606,76 @@ class SolanaWalletClient {
1606
);
1607
}
1608
1609
+ // Get or create recipient account using the sender's token program ID
1610
+ // This ensures both accounts use the same program
1611
ProgramDerivedAddress? associatedRecipientAccount;
1612
try {
1439
- associatedRecipientAccount = await _getOrCreateAssociatedTokenAccount(
1440
- payerPrivateKey: ownerPrivateKey,
1441
- mintAddress: mintAddress,
1442
- ownerAddress: SolAddress(destinationAddress),
1443
- shouldCreateATA: true,
1613
+ // First, try to get/create with the sender's actual program ID
1614
+ final recipientPDA = AssociatedTokenAccountProgramUtils.associatedTokenAccount(
1615
+ mint: mintAddress,
1616
+ owner: SolAddress(destinationAddress),
1617
+ tokenProgramId: senderTokenProgramId,
1618
);
1445
- } catch (e) {
1446
- associatedRecipientAccount = null;
1619
1448
- throw SolanaCreateAssociatedTokenAccountException(e.toString());
1449
- }
1620
+ // Check if account exists with correct program
1621
+ SolanaAccountInfo? recipientInfo;
1622
+ try {
1623
+ recipientInfo = await _provider!.request(
1624
+ SolanaRPCGetAccountInfo(
1625
+ account: recipientPDA.address,
1626
+ commitment: Commitment.confirmed,
1627
+ ),
1628
+ );
1629
+ } catch (_) {
1630
+ recipientInfo = null;
1631
+ }
1632
1451
- if (associatedRecipientAccount == null) {
1452
- throw SolanaCreateAssociatedTokenAccountException(
1453
- 'Error fetching recipient associated token account',
1454
- );
1633
+ if (recipientInfo != null && recipientInfo.owner.address == senderTokenProgramId.address) {
1634
+ associatedRecipientAccount = recipientPDA;
1635
+ } else {
1636
+ // Create the account with the correct program ID
1637
+ final createATA = AssociatedTokenAccountProgram.associatedTokenAccount(
1638
+ payer: ownerPrivateKey.publicKey().toAddress(),
1639
+ associatedToken: recipientPDA.address,
1640
+ owner: SolAddress(destinationAddress),
1641
+ mint: mintAddress,
1642
+ tokenProgramId: senderTokenProgramId,
1643
+ );
1644
+
1645
+ final blockhash = await _getLatestBlockhash(Commitment.confirmed);
1646
+ final createTransaction = SolanaTransaction(
1647
+ payerKey: ownerPrivateKey.publicKey().toAddress(),
1648
+ instructions: [createATA],
1649
+ recentBlockhash: blockhash,
1650
+ type: TransactionType.v0,
1651
+ );
1652
+
1653
+ final serializedCreateTx = await _signTransactionInternal(
1654
+ ownerPrivateKey: ownerPrivateKey,
1655
+ transaction: createTransaction,
1656
+ );
1657
+
1658
+ await sendTransaction(
1659
+ serializedTransaction: serializedCreateTx,
1660
+ commitment: Commitment.confirmed,
1661
+ );
1662
+
1663
+ await Future.delayed(const Duration(seconds: 2));
1664
+ associatedRecipientAccount = recipientPDA;
1665
+ }
1666
+ } catch (e) {
1667
+ throw SolanaCreateAssociatedTokenAccountException(e.toString());
1668
}
1669
1457
- final transferInstructions = SPLTokenProgram.transferChecked(
1458
- layout: SPLTokenTransferCheckedLayout(
1459
- amount: BigInt.from(amount),
1460
- decimals: tokenDecimals,
1461
- ),
1462
- mint: mintAddress,
1670
+ // Create transferChecked instruction with the correct token program ID
1671
+ final transferInstructions = _createTransferCheckedInstruction(
1672
+ tokenProgramId: senderTokenProgramId,
1673
source: associatedSenderAccount.address,
1674
destination: associatedRecipientAccount.address,
1675
+ mint: mintAddress,
1676
owner: ownerPrivateKey.publicKey().toAddress(),
1677
+ amount: BigInt.from(amount),
1678
+ decimals: tokenDecimals,
1679
);
1680
1681
final latestBlockHash = await _getLatestBlockhash(commitment);
@@ -1481,6 +1694,7 @@ class SolanaWalletClient {
1694
sourceAccount: associatedSenderAccount.address,
1695
amount: amount,
1696
commitment: commitment,
1697
+ tokenProgramId: tokenProgramId,
1698
);
1699
1700
final fee = await _getFeeFromCompiledMessage(message, commitment);