@exodus/solana-lib 3.16.0 → 3.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,26 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [3.18.0](https://github.com/ExodusMovement/assets/compare/@exodus/solana-lib@3.15.3...@exodus/solana-lib@3.18.0) (2025-11-26)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat: Solana support Close Authority Sponsorship in fee payer (#6767)
13
+
14
+
15
+
16
+ ## [3.17.0](https://github.com/ExodusMovement/assets/compare/@exodus/solana-lib@3.15.3...@exodus/solana-lib@3.17.0) (2025-11-13)
17
+
18
+
19
+ ### Features
20
+
21
+
22
+ * feat: Solana support Close Authority Sponsorship in fee payer (#6767)
23
+
24
+
25
+
6
26
  ## [3.16.0](https://github.com/ExodusMovement/assets/compare/@exodus/solana-lib@3.15.3...@exodus/solana-lib@3.16.0) (2025-11-11)
7
27
 
8
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/solana-lib",
3
- "version": "3.16.0",
3
+ "version": "3.18.0",
4
4
  "description": "Solana utils, such as for cryptography, address encoding/decoding, transaction building, etc.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -47,5 +47,5 @@
47
47
  "type": "git",
48
48
  "url": "git+https://github.com/ExodusMovement/assets.git"
49
49
  },
50
- "gitHead": "e8b0a919196eda921b8bc532f5632180c2bef508"
50
+ "gitHead": "4c60cc2d7f07229295b801d9f67a336d314711a0"
51
51
  }
@@ -1,63 +1,97 @@
1
- import BN from 'bn.js'
2
1
  import bs58 from 'bs58'
3
2
 
4
- import { COMPUTE_BUDGET_PROGRAM_ID, SYSTEM_PROGRAM_ID, TOKEN_PROGRAM_ID } from '../constants.js'
3
+ import { SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '../constants.js'
4
+ import {
5
+ SYSTEM_INSTRUCTION_LAYOUTS,
6
+ SystemInstruction,
7
+ TOKEN_INSTRUCTION_LAYOUTS,
8
+ TokenInstruction,
9
+ } from '../vendor/index.js'
5
10
  import { deserializeTransaction } from './common.js'
6
11
 
7
- function decodeSPLTransferData(base58Data) {
8
- const buffer = bs58.decode(base58Data)
12
+ // Helper function to check if an instruction is a token transfer
13
+ function isTokenTransferInstruction(instruction, accountKeys) {
14
+ if (!instruction || instruction.programIdIndex === undefined) return false
9
15
 
10
- // 1 byte
11
- if (buffer[0] !== 3) {
12
- throw new Error(`Unsupported instruction type: ${buffer[0]}`)
13
- }
16
+ const programId = accountKeys[instruction.programIdIndex]
14
17
 
15
- const amountBytes = buffer.slice(1, 9)
16
- const amountBN = new BN(amountBytes, 'le')
18
+ if (!programId.equals(TOKEN_PROGRAM_ID) && !programId.equals(TOKEN_2022_PROGRAM_ID)) {
19
+ return false
20
+ }
17
21
 
18
- return { amount: amountBN, method: 'transfer' }
19
- }
22
+ // Must have at least 3 accounts (from, to, authority, and potentially more for delegate transfers)
23
+ if (!Array.isArray(instruction.accounts) || instruction.accounts.length < 3) {
24
+ return false
25
+ }
20
26
 
21
- function decodeSystemTransferData(base58Data) {
22
- const buffer = bs58.decode(base58Data)
27
+ // Check if the instruction data is transfer (0x03), transferChecked (0x0c/12),
28
+ // or TransferFeeExtension (0x1a/26) with TransferCheckedWithFee sub-instruction
29
+ // Note: Must handle both formats because this is called from isTokenTransfer()
30
+ // which uses non-normalized instructions
31
+ try {
32
+ let data = instruction.data
33
+ if (typeof data === 'string') {
34
+ data = bs58.decode(data)
35
+ }
23
36
 
24
- // 4 bytes
25
- if (buffer.readUInt32LE(0) !== 2) {
26
- throw new Error(`Unsupported instruction type for SystemProgram: ${buffer.readUInt32LE(0)}`)
27
- }
37
+ const instructionType = data[0]
28
38
 
29
- const amountBytes = buffer.slice(4, 12)
30
- const amountBN = new BN(amountBytes, 'le')
39
+ // Accept Transfer, TransferChecked, and TransferFeeExtension
40
+ if (
41
+ instructionType !== TOKEN_INSTRUCTION_LAYOUTS.Transfer.index &&
42
+ instructionType !== TOKEN_INSTRUCTION_LAYOUTS.TransferChecked.index &&
43
+ instructionType !== TOKEN_INSTRUCTION_LAYOUTS.TransferFeeExtension.index
44
+ ) {
45
+ return false
46
+ }
31
47
 
32
- return {
33
- amount: amountBN,
34
- method: 'systemTransfer',
48
+ // For TransferFeeExtension, check if it's specifically TransferCheckedWithFee (sub-instruction 1)
49
+ if (instructionType === TOKEN_INSTRUCTION_LAYOUTS.TransferFeeExtension.index) {
50
+ if (data.length < 2) return false
51
+ const subInstruction = data[1]
52
+ if (subInstruction !== 1) return false // Only support TransferCheckedWithFee
53
+ }
54
+ } catch {
55
+ return false
35
56
  }
57
+
58
+ return true
36
59
  }
37
60
 
38
- // TODO support more tx types/options
39
61
  export function isTokenTransfer(tx) {
40
62
  const { message } = tx
41
63
  const { accountKeys, instructions } = message
42
64
 
43
- if (!Array.isArray(accountKeys) || accountKeys.length !== 6) return false
44
- if (!Array.isArray(instructions) || instructions.length !== 3) return false
65
+ if (!Array.isArray(accountKeys) || !Array.isArray(instructions)) return false
45
66
 
46
- const [ix1, ix2, ix3] = instructions
47
- if (
48
- !accountKeys[ix1.programIdIndex].equals(COMPUTE_BUDGET_PROGRAM_ID) ||
49
- !accountKeys[ix2.programIdIndex].equals(COMPUTE_BUDGET_PROGRAM_ID)
50
- ) {
51
- return false
52
- }
67
+ // Search for any token transfer instruction
68
+ return instructions.some((instruction) => isTokenTransferInstruction(instruction, accountKeys))
69
+ }
70
+
71
+ // Helper function to check if an instruction is a SOL transfer
72
+ function isSolTransferInstruction(instruction, accountKeys) {
73
+ if (!instruction || instruction.programIdIndex === undefined) return false
74
+
75
+ const programId = accountKeys[instruction.programIdIndex]
53
76
 
54
- if (!accountKeys[ix3.programIdIndex].equals(TOKEN_PROGRAM_ID)) return false
77
+ if (!programId.equals(SYSTEM_PROGRAM_ID)) return false
55
78
 
56
- if (!Array.isArray(ix3.accounts) || ix3.accounts.length !== 3) return false
79
+ // Must have exactly 2 accounts (from, to)
80
+ if (!Array.isArray(instruction.accounts) || instruction.accounts.length !== 2) {
81
+ return false
82
+ }
57
83
 
84
+ // Check if the instruction data starts with 0x02 (System Transfer)
85
+ // Note: Must handle both formats because this is called from isSolanaTransfer()
86
+ // which uses non-normalized instructions
58
87
  try {
59
- const data = bs58.decode(ix3.data)
60
- if (data[0] !== 0x03) return false
88
+ let data = instruction.data
89
+ if (typeof data === 'string') {
90
+ data = bs58.decode(data)
91
+ }
92
+
93
+ const buffer = Buffer.from(data)
94
+ if (buffer.readUInt32LE(0) !== SYSTEM_INSTRUCTION_LAYOUTS.Transfer.index) return false
61
95
  } catch {
62
96
  return false
63
97
  }
@@ -69,68 +103,162 @@ export function isSolanaTransfer(tx) {
69
103
  const { message } = tx
70
104
  const { accountKeys, instructions } = message
71
105
 
72
- if (!Array.isArray(accountKeys) || accountKeys.length !== 5) return false
73
- if (!Array.isArray(instructions) || instructions.length !== 3) return false
106
+ if (!Array.isArray(accountKeys) || !Array.isArray(instructions)) return false
74
107
 
75
- if (
76
- !accountKeys[instructions[0].programIdIndex].equals(COMPUTE_BUDGET_PROGRAM_ID) ||
77
- !accountKeys[instructions[1].programIdIndex].equals(COMPUTE_BUDGET_PROGRAM_ID)
78
- ) {
79
- return false
108
+ return instructions.some((instruction) => isSolTransferInstruction(instruction, accountKeys))
109
+ }
110
+
111
+ // Helper to normalize account keys for both versioned and legacy transactions
112
+ function getAccountKeys(message) {
113
+ // Versioned transactions use staticAccountKeys
114
+ if (message.staticAccountKeys) {
115
+ return message.staticAccountKeys
80
116
  }
81
117
 
82
- const ix = instructions[2]
83
- if (!accountKeys[ix.programIdIndex].equals(SYSTEM_PROGRAM_ID)) return false
118
+ // Legacy transactions use accountKeys
119
+ return message.accountKeys
120
+ }
84
121
 
85
- if (!Array.isArray(ix.accounts) || ix.accounts.length !== 2) return false
122
+ // Helper to normalize a single instruction (handles both compiled and regular instructions)
123
+ function normalizeInstruction(instruction) {
124
+ let normalizedData = instruction.data
125
+ if (typeof instruction.data === 'string') {
126
+ normalizedData = bs58.decode(instruction.data)
127
+ }
86
128
 
87
- try {
88
- const data = bs58.decode(ix.data)
89
- if (data[0] !== 0x02) return false
90
- } catch {
91
- return false
129
+ return {
130
+ programIdIndex: instruction.programIdIndex,
131
+ accounts: instruction.accountKeyIndexes || instruction.accounts,
132
+ data: normalizedData,
133
+ }
134
+ }
135
+
136
+ // Helper to normalize instructions for both versioned and legacy transactions
137
+ function getInstructions(message) {
138
+ let instructions
139
+ // Versioned transactions use compiledInstructions
140
+ if (message.compiledInstructions) {
141
+ instructions = message.compiledInstructions
142
+ } else if (message.instructions) {
143
+ // Legacy transactions use instructions
144
+ instructions = message.instructions
145
+ } else {
146
+ return []
92
147
  }
93
148
 
94
- return true
149
+ return instructions.map((inst) => normalizeInstruction(inst))
95
150
  }
96
151
 
97
152
  // TODO: Unify with parseTransaction in solana-api and use there as well?
98
- // TODO: support more tx types.
153
+ // TODO: add support for swap and stake instructions
99
154
  export async function parseTxBuffer(buffer, api) {
100
155
  const transaction = deserializeTransaction(buffer)
156
+ const { message } = transaction
157
+
158
+ // Normalize account keys and instructions (versioned vs legacy)
159
+ const accountKeys = getAccountKeys(message)
160
+ const instructions = getInstructions(message)
161
+
162
+ if (!Array.isArray(accountKeys) || !Array.isArray(instructions)) {
163
+ throw new TypeError('Invalid transaction structure')
164
+ }
165
+
166
+ const parsedInstructions = []
101
167
 
102
168
  try {
103
- if (isTokenTransfer(transaction)) {
104
- const mainInstruction = transaction.message.instructions[2]
105
- const { amount, method } = decodeSPLTransferData(mainInstruction.data)
106
-
107
- const fromTokenAddress =
108
- transaction.message.accountKeys[mainInstruction.accounts[0]].toBase58()
109
- const toTokenAddress = transaction.message.accountKeys[mainInstruction.accounts[1]].toBase58()
110
- const from = await api.getTokenAddressOwner(fromTokenAddress)
111
- const to = await api.getTokenAddressOwner(toTokenAddress)
112
- return {
113
- method,
114
- from,
115
- to,
116
- amount,
169
+ for (const [index, instruction] of instructions.entries()) {
170
+ if (isTokenTransferInstruction(instruction, accountKeys)) {
171
+ const decoded = TokenInstruction.decodeInstructionData(instruction.data)
172
+ const programId = accountKeys[instruction.programIdIndex]
173
+
174
+ // Transfer (3): [source, destination, authority]
175
+ // TransferChecked (12): [source, mint, destination, authority, ...]
176
+ // TransferCheckedWithFee (26,1): [source, mint, destination, authority, ...]
177
+ let fromTokenAccount, toTokenAccount, mintAddress
178
+
179
+ if (decoded.method === 'transferChecked' || decoded.method === 'transferCheckedWithFee') {
180
+ // TransferChecked and TransferCheckedWithFee: mint is at index 1, destination at index 2
181
+ fromTokenAccount = accountKeys[instruction.accounts[0]].toBase58()
182
+ mintAddress = accountKeys[instruction.accounts[1]].toBase58() // Mint directly from accounts
183
+ toTokenAccount = accountKeys[instruction.accounts[2]].toBase58()
184
+ } else {
185
+ // Transfer: destination is at index 1
186
+ fromTokenAccount = accountKeys[instruction.accounts[0]].toBase58()
187
+ toTokenAccount = accountKeys[instruction.accounts[1]].toBase58()
188
+ mintAddress = null
189
+ }
190
+
191
+ // Get token account owners in parallel (requires API calls)
192
+ let fromOwner, toOwner
193
+ if (api) {
194
+ ;[fromOwner, toOwner, mintAddress] = await Promise.all([
195
+ api.getTokenAddressOwner(fromTokenAccount),
196
+ api.getTokenAddressOwner(toTokenAccount),
197
+ mintAddress
198
+ ? Promise.resolve(mintAddress)
199
+ : getMintAddressFromTokenAccount(fromTokenAccount, api),
200
+ ])
201
+ // Fallback to token account address if owner lookup fails
202
+ fromOwner = fromOwner || fromTokenAccount
203
+ toOwner = toOwner || toTokenAccount
204
+ } else {
205
+ fromOwner = fromTokenAccount
206
+ toOwner = toTokenAccount
207
+ }
208
+
209
+ const parsedInstruction = {
210
+ method: decoded.method,
211
+ from: fromOwner,
212
+ to: toOwner,
213
+ amount: decoded.amount,
214
+ programId: programId.toBase58(),
215
+ instructionIndex: index,
216
+ fromTokenAccount,
217
+ toTokenAccount,
218
+ mintAddress,
219
+ }
220
+
221
+ // Include decimals if it's a transferChecked instruction
222
+ if (decoded.decimals !== undefined) {
223
+ parsedInstruction.decimals = decoded.decimals
224
+ }
225
+
226
+ parsedInstructions.push(parsedInstruction)
227
+ } else if (isSolTransferInstruction(instruction, accountKeys)) {
228
+ const { amount, method } = SystemInstruction.decodeInstructionData(instruction.data)
229
+ const programId = accountKeys[instruction.programIdIndex]
230
+
231
+ parsedInstructions.push({
232
+ method,
233
+ from: accountKeys[instruction.accounts[0]].toBase58(),
234
+ to: accountKeys[instruction.accounts[1]].toBase58(),
235
+ amount,
236
+ programId: programId.toBase58(),
237
+ instructionIndex: index,
238
+ })
117
239
  }
118
240
  }
241
+ } catch (error) {
242
+ console.log('instruction parsing error', error)
243
+ throw error
244
+ }
119
245
 
120
- if (isSolanaTransfer(transaction)) {
121
- const mainInstruction = transaction.message.instructions[2]
122
- const { amount, method } = decodeSystemTransferData(mainInstruction.data)
246
+ if (parsedInstructions.length === 0) {
247
+ throw new Error('No supported instructions found in transaction')
248
+ }
123
249
 
124
- return {
125
- method,
126
- from: transaction.message.accountKeys[mainInstruction.accounts[0]],
127
- to: transaction.message.accountKeys[mainInstruction.accounts[1]],
128
- amount,
129
- }
250
+ return parsedInstructions
251
+ }
252
+
253
+ async function getMintAddressFromTokenAccount(tokenAccountAddress, api) {
254
+ try {
255
+ const accountInfo = await api.getAccountInfo(tokenAccountAddress)
256
+ if (accountInfo?.data?.parsed?.info?.mint) {
257
+ return accountInfo.data.parsed.info.mint
130
258
  }
131
259
  } catch (error) {
132
- console.log('transaction check error', error)
260
+ console.log('Error getting mint address:', error)
133
261
  }
134
262
 
135
- throw new Error('Transaction not supported for buffer parsing')
263
+ return null
136
264
  }
@@ -9,3 +9,4 @@ export * from './stake-program.js'
9
9
  export * from './sysvar.js'
10
10
  export * from './transaction.js'
11
11
  export * from './constants.js'
12
+ export { TOKEN_INSTRUCTION_LAYOUTS, TokenInstruction } from './token-program.js'
@@ -1,4 +1,5 @@
1
1
  import * as BufferLayout from '@exodus/buffer-layout'
2
+ import BN from 'bn.js'
2
3
 
3
4
  import { decodeData, encodeData } from './instruction.js'
4
5
  import { NONCE_ACCOUNT_LENGTH } from './nonce-account.js'
@@ -258,6 +259,31 @@ export const SystemInstruction = {
258
259
  }
259
260
  },
260
261
 
262
+ /**
263
+ * Decode raw instruction data (for use in parseTxBuffer)
264
+ * @param {Buffer} data - Raw instruction data buffer
265
+ * @returns {Object} Decoded instruction data with amount and method
266
+ */
267
+ decodeInstructionData(data) {
268
+ const buffer = Buffer.from(data)
269
+
270
+ try {
271
+ const { lamports } = decodeData(SYSTEM_INSTRUCTION_LAYOUTS.Transfer, buffer)
272
+ return {
273
+ amount: new BN(lamports),
274
+ method: 'systemTransfer',
275
+ }
276
+ } catch (err) {
277
+ // Provide more specific error for non-transfer instructions
278
+ if (err.message.includes('instruction index mismatch')) {
279
+ const instructionType = buffer.readUInt32LE(0)
280
+ throw new Error(`Unsupported instruction type for SystemProgram: ${instructionType}`)
281
+ }
282
+
283
+ throw err
284
+ }
285
+ },
286
+
261
287
  /**
262
288
  * Decode an allocate system instruction and retrieve the instruction params.
263
289
  */
@@ -0,0 +1,180 @@
1
+ import BN from 'bn.js'
2
+
3
+ /**
4
+ * Token Program instruction layouts
5
+ * Token-2022 (Token Extensions) is backward compatible with these instruction types
6
+ *
7
+ * Reference: https://github.com/solana-program/token/blob/81ba155af8684c224c943af16ac3d70f5cad5e93/pinocchio/interface/src/instruction.rs
8
+ */
9
+
10
+ /**
11
+ * An enumeration of valid SPL Token InstructionType's
12
+ */
13
+ export const TOKEN_INSTRUCTION_LAYOUTS = Object.freeze({
14
+ InitializeMint: {
15
+ index: 0,
16
+ },
17
+ InitializeAccount: {
18
+ index: 1,
19
+ },
20
+ InitializeMultisig: {
21
+ index: 2,
22
+ },
23
+ Transfer: {
24
+ index: 3,
25
+ // Accounts: [source, destination, authority]
26
+ },
27
+ Approve: {
28
+ index: 4,
29
+ },
30
+ Revoke: {
31
+ index: 5,
32
+ },
33
+ SetAuthority: {
34
+ index: 6,
35
+ },
36
+ MintTo: {
37
+ index: 7,
38
+ },
39
+ Burn: {
40
+ index: 8,
41
+ },
42
+ CloseAccount: {
43
+ index: 9,
44
+ },
45
+ FreezeAccount: {
46
+ index: 10,
47
+ },
48
+ ThawAccount: {
49
+ index: 11,
50
+ },
51
+ TransferChecked: {
52
+ index: 12,
53
+ // Accounts: [source, mint, destination, authority]
54
+ },
55
+ ApproveChecked: {
56
+ index: 13,
57
+ },
58
+ MintToChecked: {
59
+ index: 14,
60
+ },
61
+ BurnChecked: {
62
+ index: 15,
63
+ },
64
+ InitializeAccount2: {
65
+ index: 16,
66
+ },
67
+ SyncNative: {
68
+ index: 17,
69
+ },
70
+ InitializeAccount3: {
71
+ index: 18,
72
+ },
73
+ InitializeMultisig2: {
74
+ index: 19,
75
+ },
76
+ InitializeMint2: {
77
+ index: 20,
78
+ },
79
+ GetAccountDataSize: {
80
+ index: 21,
81
+ },
82
+ InitializeImmutableOwner: {
83
+ index: 22,
84
+ },
85
+ AmountToUiAmount: {
86
+ index: 23,
87
+ },
88
+ UiAmountToAmount: {
89
+ index: 24,
90
+ },
91
+ // Token-2022 Extension Instructions
92
+ InitializeMintCloseAuthority: {
93
+ index: 25,
94
+ },
95
+ TransferFeeExtension: {
96
+ index: 26,
97
+ // Used for TransferCheckedWithFee operations
98
+ // Sub-instructions: InitializeTransferFeeConfig(0), TransferCheckedWithFee(1), etc.
99
+ },
100
+ WithdrawExcessLamports: {
101
+ index: 38,
102
+ },
103
+ })
104
+
105
+ /**
106
+ * TransferFeeExtension sub-instruction types
107
+ */
108
+ export const TRANSFER_FEE_SUB_INSTRUCTIONS = Object.freeze({
109
+ InitializeTransferFeeConfig: 0,
110
+ TransferCheckedWithFee: 1,
111
+ WithdrawWithheldTokensFromMint: 2,
112
+ WithdrawWithheldTokensFromAccounts: 3,
113
+ HarvestWithheldTokensToMint: 4,
114
+ SetTransferFee: 5,
115
+ })
116
+
117
+ /**
118
+ * Token Instruction class
119
+ */
120
+ export const TokenInstruction = {
121
+ /**
122
+ * Decode raw SPL Token instruction data (for use in parseTxBuffer)
123
+ * @param {Buffer} data - Instruction data buffer
124
+ * @returns {Object} Decoded instruction data with amount, method, and optional decimals/fee
125
+ */
126
+ decodeInstructionData(data) {
127
+ // Ensure we have a Buffer to work with
128
+ const buffer = Buffer.from(data)
129
+
130
+ if (buffer.length < 9) throw new Error('Instruction data too short for SPL transfer')
131
+
132
+ const instructionType = buffer[0]
133
+
134
+ // Handle TransferFeeExtension (Token-2022 instruction 26)
135
+ if (instructionType === TOKEN_INSTRUCTION_LAYOUTS.TransferFeeExtension.index) {
136
+ // TransferFeeExtension has a sub-instruction type at byte 1
137
+ if (buffer.length < 2) throw new Error('Instruction data too short for TransferFeeExtension')
138
+
139
+ const subInstructionType = buffer[1]
140
+ // TransferCheckedWithFee sub-instruction (1)
141
+ if (subInstructionType === TRANSFER_FEE_SUB_INSTRUCTIONS.TransferCheckedWithFee) {
142
+ // Format: [26, 1, amount(8), decimals(1), fee(8)]
143
+ if (buffer.length < 18) {
144
+ throw new Error('Instruction data too short for transferCheckedWithFee')
145
+ }
146
+
147
+ const amountBytes = buffer.slice(2, 10)
148
+ const amountBN = new BN(amountBytes, 'le')
149
+ const decimals = buffer[10]
150
+ const feeBytes = buffer.slice(11, 19)
151
+ const feeBN = new BN(feeBytes, 'le')
152
+
153
+ return {
154
+ amount: amountBN,
155
+ method: 'transferCheckedWithFee',
156
+ decimals,
157
+ fee: feeBN,
158
+ }
159
+ }
160
+
161
+ // Other TransferFeeExtension sub-instructions not supported yet
162
+ throw new Error(`Unsupported TransferFeeExtension sub-instruction: ${subInstructionType}`)
163
+ }
164
+
165
+ const amountBytes = buffer.slice(1, 9)
166
+ const amountBN = new BN(amountBytes, 'le')
167
+
168
+ if (instructionType === TOKEN_INSTRUCTION_LAYOUTS.Transfer.index) {
169
+ return { amount: amountBN, method: 'transfer' }
170
+ }
171
+
172
+ if (instructionType === TOKEN_INSTRUCTION_LAYOUTS.TransferChecked.index) {
173
+ if (buffer.length < 10) throw new Error('Instruction data too short for transferChecked')
174
+ const decimals = buffer[9]
175
+ return { amount: amountBN, method: 'transferChecked', decimals }
176
+ }
177
+
178
+ throw new Error(`Unsupported instruction type: ${instructionType}`)
179
+ },
180
+ }