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);