diff --git a/modules/sdk-coin-sol/src/config/token2022StaticConfig.ts b/modules/sdk-coin-sol/src/config/token2022StaticConfig.ts new file mode 100644 index 0000000000..dba9ef3855 --- /dev/null +++ b/modules/sdk-coin-sol/src/config/token2022StaticConfig.ts @@ -0,0 +1,42 @@ +import type { Token2022Config } from '../lib/token2022Config'; + +export const TOKEN_2022_STATIC_CONFIGS: Token2022Config[] = [ + { + mintAddress: '4MmJVdwYN8LwvbGeCowYjSx7KoEi6BJWg8XXnW4fDDp6', + symbol: 'tbill', + name: 'OpenEden T-Bills', + decimals: 6, + programId: 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + transferHook: { + programId: '48n7YGEww7fKMfJ5gJ3sQC3rM6RWGjpUsghqVfXVkR5A', + authority: 'CPNEkz5SaAcWqGMezXTti39ekErzMpDCtuPMGw9tt4CZ', + extraAccountMetas: [ + { + pubkey: '4zDeEh2D6K39H8Zzn99CpQkaUApbpUWfbCgqbwgZ2Yf', + isSigner: false, + isWritable: true, + }, + ], + extraAccountMetasPDA: '9sQhAH7vV3RKTCK13VY4EiNjs3qBq1srSYxdNufdAAXm', + }, + }, + { + mintAddress: '3BW95VLH2za2eUQ1PGfjxwMbpsnDFnmkA7m5LDgMKbX7', + symbol: 't1test', + name: 'T1TEST', + decimals: 6, + programId: 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', + transferHook: { + programId: '2Te6MFDwstRP2sZi6DLbkhVcSfaQVffmpbudN6pmvAXo', + authority: 'BLZvvaQgPUvL2RWoJeovudbHMhqH4S3kdenN5eg1juDr', + extraAccountMetas: [ + { + pubkey: '4zDeEh2D6K39H8Zzn99CpQkaUApbpUWfbCgqbwgZ2Yf', + isSigner: false, + isWritable: true, + }, + ], + extraAccountMetasPDA: 'FR5YBEisx8mDe4ruhWKmpH5nirdJopj4uStBAVufqjMo', + }, + }, +]; diff --git a/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts b/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts index 0706687ac5..0d7e534da6 100644 --- a/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts +++ b/modules/sdk-coin-sol/src/lib/solInstructionFactory.ts @@ -10,6 +10,7 @@ import { createApproveInstruction, } from '@solana/spl-token'; import { + AccountMeta, Authorized, Lockup, PublicKey, @@ -45,6 +46,7 @@ import { } from './iface'; import { getSolTokenFromTokenName, isValidBase64, isValidHex } from './utils'; import { depositSolInstructions, withdrawStakeInstructions } from './jitoStakePoolOperations'; +import { getToken2022Config, TransferHookConfig } from './token2022Config'; /** * Construct Solana instructions from instructions params @@ -193,7 +195,10 @@ function tokenTransferInstruction(data: TokenTransfer): TransactionInstruction[] } let transferInstruction: TransactionInstruction; + const instructions: TransactionInstruction[] = []; + if (programId === TOKEN_2022_PROGRAM_ID.toString()) { + // Create the base transfer instruction transferInstruction = createTransferCheckedInstruction( new PublicKey(sourceAddress), new PublicKey(tokenAddress), @@ -204,6 +209,11 @@ function tokenTransferInstruction(data: TokenTransfer): TransactionInstruction[] [], TOKEN_2022_PROGRAM_ID ); + // Check if this token has a transfer hook configuration + const tokenConfig = getToken2022Config(tokenAddress); + if (tokenConfig?.transferHook) { + addTransferHookAccounts(transferInstruction, tokenConfig.transferHook); + } } else { transferInstruction = createTransferCheckedInstruction( new PublicKey(sourceAddress), @@ -214,7 +224,8 @@ function tokenTransferInstruction(data: TokenTransfer): TransactionInstruction[] decimalPlaces ); } - return [transferInstruction]; + instructions.push(transferInstruction); + return instructions; } /** @@ -686,3 +697,40 @@ function customInstruction(data: InstructionParams): TransactionInstruction[] { return [convertedInstruction]; } + +function upsertAccountMeta(keys: AccountMeta[], meta: AccountMeta): void { + const existing = keys.find((account) => account.pubkey.equals(meta.pubkey)); + if (existing) { + existing.isWritable = existing.isWritable || meta.isWritable; + existing.isSigner = existing.isSigner || meta.isSigner; + } else { + keys.push(meta); + } +} + +function buildStaticTransferHookAccounts(transferHook: TransferHookConfig): AccountMeta[] { + const metas: AccountMeta[] = []; + if (transferHook.extraAccountMetas?.length) { + for (const meta of transferHook.extraAccountMetas) { + metas.push({ + pubkey: new PublicKey(meta.pubkey), + isSigner: meta.isSigner, + isWritable: meta.isWritable, + }); + } + } + metas.push({ pubkey: new PublicKey(transferHook.programId), isSigner: false, isWritable: false }); + + if (transferHook.extraAccountMetasPDA) { + metas.push({ pubkey: new PublicKey(transferHook.extraAccountMetasPDA), isSigner: false, isWritable: false }); + } + + return metas; +} + +function addTransferHookAccounts(instruction: TransactionInstruction, transferHook: TransferHookConfig): void { + const extraMetas = buildStaticTransferHookAccounts(transferHook); + for (const meta of extraMetas) { + upsertAccountMeta(instruction.keys, meta); + } +} diff --git a/modules/sdk-coin-sol/src/lib/token2022Config.ts b/modules/sdk-coin-sol/src/lib/token2022Config.ts new file mode 100644 index 0000000000..b22a5b06be --- /dev/null +++ b/modules/sdk-coin-sol/src/lib/token2022Config.ts @@ -0,0 +1,85 @@ +/** + * Token-2022 Configuration for Solana tokens with transfer hooks + * This file contains static configurations for Token-2022 tokens to avoid RPC calls + * when building transfer transactions with transfer hooks. + */ + +import { TOKEN_2022_STATIC_CONFIGS } from '../config/token2022StaticConfig'; + +/** + * Interface for extra account metadata needed by transfer hooks + */ +export interface ExtraAccountMeta { + /** The public key of the account */ + pubkey: string; + /** Whether the account is a signer */ + isSigner: boolean; + /** Whether the account is writable */ + isWritable: boolean; + /** Optional seed for PDA derivation */ + seeds?: Array<{ + /** Literal seed value or instruction account index reference */ + value: string | number; + /** Type of seed: 'literal' for string/buffer, 'accountKey' for instruction account index */ + type: 'literal' | 'accountKey'; + }>; +} + +/** + * Interface for transfer hook configuration + */ +export interface TransferHookConfig { + /** The transfer hook program ID */ + programId: string; + /** The transfer hook authority */ + authority: string; + /** Extra account metas required by the transfer hook */ + extraAccountMetas: ExtraAccountMeta[]; + /** The PDA address for extra account metas (cached) */ + extraAccountMetasPDA?: string; +} + +/** + * Interface for Token-2022 configuration + */ +export interface Token2022Config { + /** The mint address of the token */ + mintAddress: string; + /** Token symbol */ + symbol: string; + /** Token name */ + name: string; + /** Number of decimal places */ + decimals: number; + /** Program ID (TOKEN_2022_PROGRAM_ID) */ + programId: string; + /** Transfer hook configuration if applicable */ + transferHook?: TransferHookConfig; + /** Whether the token has transfer fees */ + hasTransferFees?: boolean; +} + +/** + * Token configurations map + * Key: mintAddress or symbol + */ +export const TOKEN_2022_CONFIGS: Record = {}; + +TOKEN_2022_STATIC_CONFIGS.forEach((config) => { + TOKEN_2022_CONFIGS[config.mintAddress] = config; + TOKEN_2022_CONFIGS[config.symbol] = config; +}); + +// Create symbol mappings for convenience +Object.values(TOKEN_2022_CONFIGS).forEach((config) => { + TOKEN_2022_CONFIGS[config.symbol] = config; +}); + +/** + * Get token configuration by mint address + * @param mintAddress - The mint address of the token + * @returns Token configuration or undefined if not found + */ +export function getToken2022Config(mintAddress: string): Token2022Config | undefined { + return TOKEN_2022_CONFIGS[mintAddress]; +} diff --git a/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts b/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts index 19605eae7b..75545dbe22 100644 --- a/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts +++ b/modules/sdk-coin-sol/test/unit/solInstructionFactory.ts @@ -1,6 +1,7 @@ import should from 'should'; import * as testData from '../resources/sol'; import { solInstructionFactory } from '../../src/lib/solInstructionFactory'; +import { getToken2022Config } from '../../src/lib/token2022Config'; import { InstructionBuilderTypes, MEMO_PROGRAM_PK } from '../../src/lib/constants'; import { InstructionParams } from '../../src/lib/iface'; import { PublicKey, SystemProgram, TransactionInstruction } from '@solana/web3.js'; @@ -149,6 +150,75 @@ describe('Instruction Builder Tests: ', function () { ]); }); + it('Token Transfer - Token-2022 with transfer hook config', () => { + const tokenConfig = getToken2022Config('4MmJVdwYN8LwvbGeCowYjSx7KoEi6BJWg8XXnW4fDDp6'); + should.exist(tokenConfig); + should.exist(tokenConfig?.transferHook); + const transferHook = tokenConfig!.transferHook!; + + const fromAddress = testData.authAccount.pub; + const toAddress = testData.nonceAccount.pub; + const sourceAddress = testData.associatedTokenAccounts.accounts[0].ata; + const amount = '500000'; + + const transferParams: InstructionParams = { + type: InstructionBuilderTypes.TokenTransfer, + params: { + fromAddress, + toAddress, + amount, + tokenName: tokenConfig!.symbol, + sourceAddress, + tokenAddress: tokenConfig!.mintAddress, + decimalPlaces: tokenConfig!.decimals, + programId: tokenConfig!.programId, + }, + }; + + const result = solInstructionFactory(transferParams); + result.should.have.length(1); + + const builtInstruction = result[0]; + builtInstruction.programId.equals(TOKEN_2022_PROGRAM_ID).should.be.true(); + + const baseInstruction = createTransferCheckedInstruction( + new PublicKey(sourceAddress), + new PublicKey(tokenConfig!.mintAddress), + new PublicKey(toAddress), + new PublicKey(fromAddress), + BigInt(amount), + tokenConfig!.decimals, + [], + TOKEN_2022_PROGRAM_ID + ); + + const baseKeyCount = baseInstruction.keys.length; + builtInstruction.keys.slice(0, baseKeyCount).should.deepEqual(baseInstruction.keys); + + const extraKeys = builtInstruction.keys.slice(baseKeyCount); + const expectedExtraKeys = [ + ...transferHook.extraAccountMetas.map((meta) => ({ + pubkey: new PublicKey(meta.pubkey), + isSigner: meta.isSigner, + isWritable: meta.isWritable, + })), + { pubkey: new PublicKey(transferHook.programId), isSigner: false, isWritable: false }, + ]; + + if (transferHook.extraAccountMetasPDA) { + expectedExtraKeys.push({ + pubkey: new PublicKey(transferHook.extraAccountMetasPDA), + isSigner: false, + isWritable: false, + }); + } + extraKeys.should.deepEqual(expectedExtraKeys); + + for (const expectedMeta of expectedExtraKeys) { + builtInstruction.keys.filter((meta) => meta.pubkey.equals(expectedMeta.pubkey)).should.have.length(1); + } + }); + it('Mint To - Standard SPL Token', () => { const mintAddress = testData.tokenTransfers.mintUSDC; const destinationAddress = testData.tokenTransfers.sourceUSDC; diff --git a/modules/statics/src/coins/solTokens.ts b/modules/statics/src/coins/solTokens.ts index 5dc07aaab9..5a98ecf854 100644 --- a/modules/statics/src/coins/solTokens.ts +++ b/modules/statics/src/coins/solTokens.ts @@ -3172,7 +3172,7 @@ export const solTokens = [ '50a59f79-033b-4bd0-aae1-49270f97cae2', 'tsol:t1test', 'T1TEST', - 9, + 6, '3BW95VLH2za2eUQ1PGfjxwMbpsnDFnmkA7m5LDgMKbX7', '3BW95VLH2za2eUQ1PGfjxwMbpsnDFnmkA7m5LDgMKbX7', UnderlyingAsset['tsol:t1test'],