@exodus/solana-lib 3.15.3 → 3.17.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.17.0](https://github.com/ExodusMovement/assets/compare/@exodus/solana-lib@3.15.3...@exodus/solana-lib@3.17.0) (2025-11-13)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat: Solana support Close Authority Sponsorship in fee payer (#6767)
13
+
14
+
15
+
16
+ ## [3.16.0](https://github.com/ExodusMovement/assets/compare/@exodus/solana-lib@3.15.3...@exodus/solana-lib@3.16.0) (2025-11-11)
17
+
18
+
19
+ ### Features
20
+
21
+
22
+ * feat: Solana support Close Authority Sponsorship in fee payer (#6767)
23
+
24
+
25
+
6
26
  ## [3.15.3](https://github.com/ExodusMovement/assets/compare/@exodus/solana-lib@3.15.1...@exodus/solana-lib@3.15.3) (2025-11-06)
7
27
 
8
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/solana-lib",
3
- "version": "3.15.3",
3
+ "version": "3.17.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": "4ed7fb1a57f5d2728f3e491f023144a4ccf3a1d4"
50
+ "gitHead": "6a39e3cdbf3bf1849d6d4aae8e935588332d92ac"
51
51
  }
package/src/constants.js CHANGED
@@ -36,3 +36,22 @@ export const LAMPORTS_PER_SOL = 1_000_000_000
36
36
  export const SOL_DECIMAL = Math.log10(LAMPORTS_PER_SOL)
37
37
 
38
38
  export const SUPPORTED_TRANSACTION_VERSIONS = new Set(['legacy', 0])
39
+
40
+ export const SPL_TOKEN_AUTHORITY_TYPE = {
41
+ MINT_TOKENS: 0,
42
+ FREEZE_ACCOUNT: 1,
43
+ ACCOUNT_OWNER: 2,
44
+ CLOSE_ACCOUNT: 3,
45
+ }
46
+
47
+ export const SPL_TOKEN_INSTRUCTION_TYPE = {
48
+ INITIALIZE_MINT: 0,
49
+ INITIALIZE_ACCOUNT: 1,
50
+ INITIALIZE_MULTISIG: 2,
51
+ TRANSFER: 3,
52
+ APPROVE: 4,
53
+ REVOKE: 5,
54
+ SET_AUTHORITY: 6,
55
+ MINT_TO: 7,
56
+ BURN: 8,
57
+ }
@@ -1,28 +1,36 @@
1
1
  import BN from 'bn.js'
2
2
  import bs58 from 'bs58'
3
3
 
4
- import { COMPUTE_BUDGET_PROGRAM_ID, SYSTEM_PROGRAM_ID, TOKEN_PROGRAM_ID } from '../constants.js'
4
+ import { SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '../constants.js'
5
+ import { SYSTEM_INSTRUCTION_LAYOUTS, TOKEN_INSTRUCTION_LAYOUTS } from '../vendor/index.js'
5
6
  import { deserializeTransaction } from './common.js'
6
7
 
7
- function decodeSPLTransferData(base58Data) {
8
- const buffer = bs58.decode(base58Data)
8
+ function decodeSPLTransferData(data) {
9
+ // Data is already normalized to Buffer in normalizeInstruction
10
+ if (data.length < 9) throw new Error('Instruction data too short for SPL transfer')
9
11
 
10
- // 1 byte
11
- if (buffer[0] !== 3) {
12
- throw new Error(`Unsupported instruction type: ${buffer[0]}`)
12
+ const instructionType = data[0]
13
+ const amountBytes = data.slice(1, 9)
14
+ const amountBN = new BN(amountBytes, 'le')
15
+
16
+ if (instructionType === TOKEN_INSTRUCTION_LAYOUTS.Transfer.index) {
17
+ return { amount: amountBN, method: 'transfer' }
13
18
  }
14
19
 
15
- const amountBytes = buffer.slice(1, 9)
16
- const amountBN = new BN(amountBytes, 'le')
20
+ if (instructionType === TOKEN_INSTRUCTION_LAYOUTS.TransferChecked.index) {
21
+ if (data.length < 10) throw new Error('Instruction data too short for transferChecked')
22
+ const decimals = data[9]
23
+ return { amount: amountBN, method: 'transferChecked', decimals }
24
+ }
17
25
 
18
- return { amount: amountBN, method: 'transfer' }
26
+ throw new Error(`Unsupported instruction type: ${instructionType}`)
19
27
  }
20
28
 
21
- function decodeSystemTransferData(base58Data) {
22
- const buffer = bs58.decode(base58Data)
29
+ function decodeSystemTransferData(data) {
30
+ const buffer = Buffer.from(data)
23
31
 
24
- // 4 bytes
25
- if (buffer.readUInt32LE(0) !== 2) {
32
+ // 4 bytes - instruction type must be 2 (Transfer)
33
+ if (buffer.readUInt32LE(0) !== SYSTEM_INSTRUCTION_LAYOUTS.Transfer.index) {
26
34
  throw new Error(`Unsupported instruction type for SystemProgram: ${buffer.readUInt32LE(0)}`)
27
35
  }
28
36
 
@@ -35,29 +43,38 @@ function decodeSystemTransferData(base58Data) {
35
43
  }
36
44
  }
37
45
 
38
- // TODO support more tx types/options
39
- export function isTokenTransfer(tx) {
40
- const { message } = tx
41
- const { accountKeys, instructions } = message
46
+ // Helper function to check if an instruction is a token transfer
47
+ function isTokenTransferInstruction(instruction, accountKeys) {
48
+ if (!instruction || instruction.programIdIndex === undefined) return false
42
49
 
43
- if (!Array.isArray(accountKeys) || accountKeys.length !== 6) return false
44
- if (!Array.isArray(instructions) || instructions.length !== 3) return false
50
+ const programId = accountKeys[instruction.programIdIndex]
45
51
 
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
- ) {
52
+ if (!programId.equals(TOKEN_PROGRAM_ID) && !programId.equals(TOKEN_2022_PROGRAM_ID)) {
51
53
  return false
52
54
  }
53
55
 
54
- if (!accountKeys[ix3.programIdIndex].equals(TOKEN_PROGRAM_ID)) return false
55
-
56
- if (!Array.isArray(ix3.accounts) || ix3.accounts.length !== 3) return false
56
+ // Must have at least 3 accounts (from, to, authority, and potentially more for delegate transfers)
57
+ if (!Array.isArray(instruction.accounts) || instruction.accounts.length < 3) {
58
+ return false
59
+ }
57
60
 
61
+ // Check if the instruction data is transfer (0x03) or transferChecked (0x0c/12)
62
+ // Note: Must handle both formats because this is called from isTokenTransfer()
63
+ // which uses non-normalized instructions
58
64
  try {
59
- const data = bs58.decode(ix3.data)
60
- if (data[0] !== 0x03) return false
65
+ let data = instruction.data
66
+ if (typeof data === 'string') {
67
+ data = bs58.decode(data)
68
+ }
69
+
70
+ const instructionType = data[0]
71
+ // Accept both Transfer and TransferChecked
72
+ if (
73
+ instructionType !== TOKEN_INSTRUCTION_LAYOUTS.Transfer.index &&
74
+ instructionType !== TOKEN_INSTRUCTION_LAYOUTS.TransferChecked.index
75
+ ) {
76
+ return false
77
+ }
61
78
  } catch {
62
79
  return false
63
80
  }
@@ -65,28 +82,40 @@ export function isTokenTransfer(tx) {
65
82
  return true
66
83
  }
67
84
 
68
- export function isSolanaTransfer(tx) {
85
+ export function isTokenTransfer(tx) {
69
86
  const { message } = tx
70
87
  const { accountKeys, instructions } = message
71
88
 
72
- if (!Array.isArray(accountKeys) || accountKeys.length !== 5) return false
73
- if (!Array.isArray(instructions) || instructions.length !== 3) return false
89
+ if (!Array.isArray(accountKeys) || !Array.isArray(instructions)) return false
74
90
 
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
80
- }
91
+ // Search for any token transfer instruction
92
+ return instructions.some((instruction) => isTokenTransferInstruction(instruction, accountKeys))
93
+ }
94
+
95
+ // Helper function to check if an instruction is a SOL transfer
96
+ function isSolTransferInstruction(instruction, accountKeys) {
97
+ if (!instruction || instruction.programIdIndex === undefined) return false
98
+
99
+ const programId = accountKeys[instruction.programIdIndex]
81
100
 
82
- const ix = instructions[2]
83
- if (!accountKeys[ix.programIdIndex].equals(SYSTEM_PROGRAM_ID)) return false
101
+ if (!programId.equals(SYSTEM_PROGRAM_ID)) return false
84
102
 
85
- if (!Array.isArray(ix.accounts) || ix.accounts.length !== 2) return false
103
+ // Must have exactly 2 accounts (from, to)
104
+ if (!Array.isArray(instruction.accounts) || instruction.accounts.length !== 2) {
105
+ return false
106
+ }
86
107
 
108
+ // Check if the instruction data starts with 0x02 (System Transfer)
109
+ // Note: Must handle both formats because this is called from isSolanaTransfer()
110
+ // which uses non-normalized instructions
87
111
  try {
88
- const data = bs58.decode(ix.data)
89
- if (data[0] !== 0x02) return false
112
+ let data = instruction.data
113
+ if (typeof data === 'string') {
114
+ data = bs58.decode(data)
115
+ }
116
+
117
+ const buffer = Buffer.from(data)
118
+ if (buffer.readUInt32LE(0) !== SYSTEM_INSTRUCTION_LAYOUTS.Transfer.index) return false
90
119
  } catch {
91
120
  return false
92
121
  }
@@ -94,43 +123,165 @@ export function isSolanaTransfer(tx) {
94
123
  return true
95
124
  }
96
125
 
126
+ export function isSolanaTransfer(tx) {
127
+ const { message } = tx
128
+ const { accountKeys, instructions } = message
129
+
130
+ if (!Array.isArray(accountKeys) || !Array.isArray(instructions)) return false
131
+
132
+ return instructions.some((instruction) => isSolTransferInstruction(instruction, accountKeys))
133
+ }
134
+
135
+ // Helper to normalize account keys for both versioned and legacy transactions
136
+ function getAccountKeys(message) {
137
+ // Versioned transactions use staticAccountKeys
138
+ if (message.staticAccountKeys) {
139
+ return message.staticAccountKeys
140
+ }
141
+
142
+ // Legacy transactions use accountKeys
143
+ return message.accountKeys
144
+ }
145
+
146
+ // Helper to normalize a single instruction (handles both compiled and regular instructions)
147
+ function normalizeInstruction(instruction) {
148
+ let normalizedData = instruction.data
149
+ if (typeof instruction.data === 'string') {
150
+ normalizedData = bs58.decode(instruction.data)
151
+ }
152
+
153
+ return {
154
+ programIdIndex: instruction.programIdIndex,
155
+ accounts: instruction.accountKeyIndexes || instruction.accounts,
156
+ data: normalizedData,
157
+ }
158
+ }
159
+
160
+ // Helper to normalize instructions for both versioned and legacy transactions
161
+ function getInstructions(message) {
162
+ let instructions
163
+ // Versioned transactions use compiledInstructions
164
+ if (message.compiledInstructions) {
165
+ instructions = message.compiledInstructions
166
+ } else if (message.instructions) {
167
+ // Legacy transactions use instructions
168
+ instructions = message.instructions
169
+ } else {
170
+ return []
171
+ }
172
+
173
+ return instructions.map((inst) => normalizeInstruction(inst))
174
+ }
175
+
97
176
  // TODO: Unify with parseTransaction in solana-api and use there as well?
98
- // TODO: support more tx types.
177
+ // TODO: add support for swap and stake instructions
99
178
  export async function parseTxBuffer(buffer, api) {
100
179
  const transaction = deserializeTransaction(buffer)
180
+ const { message } = transaction
181
+
182
+ // Normalize account keys and instructions (versioned vs legacy)
183
+ const accountKeys = getAccountKeys(message)
184
+ const instructions = getInstructions(message)
185
+
186
+ if (!Array.isArray(accountKeys) || !Array.isArray(instructions)) {
187
+ throw new TypeError('Invalid transaction structure')
188
+ }
189
+
190
+ const parsedInstructions = []
101
191
 
102
192
  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,
193
+ for (const [index, instruction] of instructions.entries()) {
194
+ if (isTokenTransferInstruction(instruction, accountKeys)) {
195
+ const decoded = decodeSPLTransferData(instruction.data)
196
+ const programId = accountKeys[instruction.programIdIndex]
197
+
198
+ // Transfer (3): [source, destination, authority]
199
+ // TransferChecked (12): [source, mint, destination, authority, ...]
200
+ let fromTokenAccount, toTokenAccount, mintAddress
201
+
202
+ if (decoded.method === 'transferChecked') {
203
+ // TransferChecked: mint is at index 1, destination at index 2
204
+ fromTokenAccount = accountKeys[instruction.accounts[0]].toBase58()
205
+ mintAddress = accountKeys[instruction.accounts[1]].toBase58() // Mint directly from accounts
206
+ toTokenAccount = accountKeys[instruction.accounts[2]].toBase58()
207
+ } else {
208
+ // Transfer: destination is at index 1
209
+ fromTokenAccount = accountKeys[instruction.accounts[0]].toBase58()
210
+ toTokenAccount = accountKeys[instruction.accounts[1]].toBase58()
211
+ mintAddress = null
212
+ }
213
+
214
+ // Get token account owners in parallel (requires API calls)
215
+ let fromOwner, toOwner
216
+ if (api) {
217
+ ;[fromOwner, toOwner, mintAddress] = await Promise.all([
218
+ api.getTokenAddressOwner(fromTokenAccount),
219
+ api.getTokenAddressOwner(toTokenAccount),
220
+ mintAddress
221
+ ? Promise.resolve(mintAddress)
222
+ : getMintAddressFromTokenAccount(fromTokenAccount, api),
223
+ ])
224
+ // Fallback to token account address if owner lookup fails
225
+ fromOwner = fromOwner || fromTokenAccount
226
+ toOwner = toOwner || toTokenAccount
227
+ } else {
228
+ fromOwner = fromTokenAccount
229
+ toOwner = toTokenAccount
230
+ }
231
+
232
+ const parsedInstruction = {
233
+ method: decoded.method,
234
+ from: fromOwner,
235
+ to: toOwner,
236
+ amount: decoded.amount,
237
+ programId: programId.toBase58(),
238
+ instructionIndex: index,
239
+ fromTokenAccount,
240
+ toTokenAccount,
241
+ mintAddress,
242
+ }
243
+
244
+ // Include decimals if it's a transferChecked instruction
245
+ if (decoded.decimals !== undefined) {
246
+ parsedInstruction.decimals = decoded.decimals
247
+ }
248
+
249
+ parsedInstructions.push(parsedInstruction)
250
+ } else if (isSolTransferInstruction(instruction, accountKeys)) {
251
+ const { amount, method } = decodeSystemTransferData(instruction.data)
252
+ const programId = accountKeys[instruction.programIdIndex]
253
+
254
+ parsedInstructions.push({
255
+ method,
256
+ from: accountKeys[instruction.accounts[0]].toBase58(),
257
+ to: accountKeys[instruction.accounts[1]].toBase58(),
258
+ amount,
259
+ programId: programId.toBase58(),
260
+ instructionIndex: index,
261
+ })
117
262
  }
118
263
  }
264
+ } catch (error) {
265
+ console.log('instruction parsing error', error)
266
+ throw error
267
+ }
119
268
 
120
- if (isSolanaTransfer(transaction)) {
121
- const mainInstruction = transaction.message.instructions[2]
122
- const { amount, method } = decodeSystemTransferData(mainInstruction.data)
269
+ if (parsedInstructions.length === 0) {
270
+ throw new Error('No supported instructions found in transaction')
271
+ }
123
272
 
124
- return {
125
- method,
126
- from: transaction.message.accountKeys[mainInstruction.accounts[0]],
127
- to: transaction.message.accountKeys[mainInstruction.accounts[1]],
128
- amount,
129
- }
273
+ return parsedInstructions
274
+ }
275
+
276
+ async function getMintAddressFromTokenAccount(tokenAccountAddress, api) {
277
+ try {
278
+ const accountInfo = await api.getAccountInfo(tokenAccountAddress)
279
+ if (accountInfo?.data?.parsed?.info?.mint) {
280
+ return accountInfo.data.parsed.info.mint
130
281
  }
131
282
  } catch (error) {
132
- console.log('transaction check error', error)
283
+ console.log('Error getting mint address:', error)
133
284
  }
134
285
 
135
- throw new Error('Transaction not supported for buffer parsing')
286
+ return null
136
287
  }
@@ -1,7 +1,12 @@
1
1
  import lodash from 'lodash'
2
2
  import assert from 'minimalistic-assert'
3
3
 
4
- import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '../constants.js'
4
+ import {
5
+ SPL_TOKEN_AUTHORITY_TYPE,
6
+ SPL_TOKEN_INSTRUCTION_TYPE,
7
+ TOKEN_2022_PROGRAM_ID,
8
+ TOKEN_PROGRAM_ID,
9
+ } from '../constants.js'
5
10
  import { ASSOCIATED_TOKEN_PROGRAM_ID } from '../helpers/spl-token.js'
6
11
  import { SYSVAR_RENT_PUBKEY } from '../vendor/index.js'
7
12
 
@@ -19,16 +24,16 @@ export function verifyOnlyFeePayerChanged(beforeTx, afterTx) {
19
24
  )
20
25
  })
21
26
  assert(
22
- beforeTx.message.accountKeys.length + 1 === afterTx.message.accountKeys.length,
23
- 'Fee payer account key was not added'
27
+ beforeTx.message.accountKeys.length <= afterTx.message.accountKeys.length,
28
+ 'Account keys were removed'
24
29
  )
25
30
  assert(
26
31
  beforeTx.message.accountKeys.every(
27
32
  (beforeAccountKey) => !lodash.isEqual(beforeAccountKey, afterTx.message.accountKeys[0])
28
33
  ),
29
- 'Fee payer account key was not added'
34
+ 'Fee payer account key was not added as first account'
30
35
  )
31
- beforeTx.message.accountKeys.forEach((accountKey, index) => {
36
+ beforeTx.message.accountKeys.forEach((accountKey) => {
32
37
  assert(
33
38
  afterTx.message.accountKeys.some((afterAccountKey) =>
34
39
  lodash.isEqual(accountKey, afterAccountKey)
@@ -37,69 +42,152 @@ export function verifyOnlyFeePayerChanged(beforeTx, afterTx) {
37
42
  )
38
43
  })
39
44
 
40
- assert(
41
- beforeTx.message.instructions.length === afterTx.message.instructions.length,
42
- 'No new instructions are allowed'
43
- )
45
+ const originalInstructionCount = beforeTx.message.instructions.length
46
+ const sponsoredInstructionCount = afterTx.message.instructions.length
47
+ assert(originalInstructionCount <= sponsoredInstructionCount, 'Instructions were removed')
48
+
49
+ beforeTx.message.instructions.forEach((instruction, index) => {
50
+ const afterInstruction = afterTx.message.instructions[index]
44
51
 
45
- beforeTx.message.instructions.forEach(({ programIdIndex }, index) => {
46
52
  assert(
47
53
  lodash.isEqual(
48
- beforeTx.message.accountKeys[beforeTx.message.instructions[index].programIdIndex],
49
- afterTx.message.accountKeys[afterTx.message.instructions[index]?.programIdIndex]
54
+ beforeTx.message.accountKeys[instruction.programIdIndex],
55
+ afterTx.message.accountKeys[afterInstruction.programIdIndex]
50
56
  ),
51
- 'Instructions program ids were not updated'
57
+ 'Instructions program ids were not updated correctly'
52
58
  )
53
- })
54
59
 
55
- beforeTx.message.instructions.forEach(({ accounts }, index) => {
56
- const programId =
57
- beforeTx.message.accountKeys[beforeTx.message.instructions[index].programIdIndex].toString()
58
- const isATAProgram =
59
- programId === TOKEN_PROGRAM_ID.toString() ||
60
- programId === TOKEN_2022_PROGRAM_ID.toString() ||
61
- programId === ASSOCIATED_TOKEN_PROGRAM_ID.toString()
62
- const accountsPublicKeys = accounts.map((id) => beforeTx.message.accountKeys[id])
63
- const containsRentSysvar = accountsPublicKeys.some(
64
- (publicKey) => publicKey.toString() === SYSVAR_RENT_PUBKEY.toString()
60
+ assert(
61
+ lodash.isEqual(instruction.data, afterInstruction.data),
62
+ 'Fee payer service modified instruction data unexpectedly'
65
63
  )
66
64
 
67
- const afterAccountsPublicKeys = afterTx.message.instructions[index].accounts.map(
68
- (id) => afterTx.message.accountKeys[id]
69
- )
65
+ const beforeAccounts = instruction.accounts.map((id) => beforeTx.message.accountKeys[id])
66
+ const afterAccounts = afterInstruction.accounts.map((id) => afterTx.message.accountKeys[id])
67
+
68
+ const programId = beforeTx.message.accountKeys[instruction.programIdIndex].toString()
69
+
70
+ const containsRentSysvar = beforeAccounts.some((publicKey) => {
71
+ const keyStr = publicKey?.toString ? publicKey.toString() : String(publicKey)
72
+ return keyStr === SYSVAR_RENT_PUBKEY.toString()
73
+ })
70
74
 
71
- if (containsRentSysvar && isATAProgram) {
72
- const adjustedAccountsPublicKeys = [...accountsPublicKeys]
73
- adjustedAccountsPublicKeys[0] = afterTx.message.accountKeys[0] // replace with the new fee payer
75
+ if (containsRentSysvar && isATAProgram(programId)) {
76
+ const adjustedBeforeAccounts = [...beforeAccounts]
77
+ adjustedBeforeAccounts[0] = afterTx.message.accountKeys[0]
74
78
  assert(
75
- lodash.isEqual(adjustedAccountsPublicKeys, afterAccountsPublicKeys),
76
- 'Instructions account key indexes were not updated'
79
+ lodash.isEqual(adjustedBeforeAccounts, afterAccounts),
80
+ 'Instruction account keys were not updated correctly'
77
81
  )
78
82
  } else {
79
83
  assert(
80
- lodash.isEqual(accountsPublicKeys, afterAccountsPublicKeys),
81
- 'Instructions account key indexes were not updated'
84
+ lodash.isEqual(beforeAccounts, afterAccounts),
85
+ 'Instruction account keys were not updated correctly'
82
86
  )
83
87
  }
84
88
  })
85
89
 
86
- beforeTx.message.instructions.forEach((instruction, index) => {
87
- assert(
88
- lodash.isEqual(
89
- { ...instruction, accounts: null, programIdIndex: null },
90
- {
91
- ...afterTx.message.instructions[index],
92
- accounts: null,
93
- programIdIndex: null,
94
- }
95
- ),
96
- 'Instructions do not match in some attributes'
97
- )
98
- })
90
+ // If there are appended instructions, validate they are SetAuthority(CloseAccount)
91
+ if (sponsoredInstructionCount > originalInstructionCount) {
92
+ const expectedProtectedAccounts = getTokenAccountCreations(beforeTx)
93
+ const appendedCount = sponsoredInstructionCount - originalInstructionCount
99
94
 
100
- afterTx.message.indexToProgramIds.forEach((value, key) => {
101
- assert(afterTx.message.accountKeys[key] === value, 'IndexToProgramIds do not match accountKeys')
102
- })
95
+ // If token accounts are being created, verify exact count match
96
+ if (expectedProtectedAccounts.length > 0) {
97
+ // Zero-trust mode: Server should add exactly one SetAuthority per sponsored token account
98
+ assert(
99
+ appendedCount === expectedProtectedAccounts.length,
100
+ `Expected ${expectedProtectedAccounts.length} SetAuthority instructions for created token accounts, but got ${appendedCount}`
101
+ )
102
+ }
103
+
104
+ const protectedAccountsMap = new Map()
105
+
106
+ for (let i = originalInstructionCount; i < sponsoredInstructionCount; i++) {
107
+ const instruction = afterTx.message.instructions[i]
108
+ const programId = afterTx.message.accountKeys[instruction.programIdIndex]
109
+
110
+ assert(
111
+ isTokenProgram(programId),
112
+ `Appended instruction ${i - originalInstructionCount + 1} is not from a token program`
113
+ )
114
+
115
+ const data = instruction.data
116
+ assert(
117
+ data && data.length > 0,
118
+ `Appended instruction ${i - originalInstructionCount + 1} has no data`
119
+ )
120
+
121
+ const instructionType = data[0]
122
+ assert(
123
+ instructionType === SPL_TOKEN_INSTRUCTION_TYPE.SET_AUTHORITY,
124
+ `Appended instruction ${i - originalInstructionCount + 1} is not SetAuthority`
125
+ )
126
+
127
+ assert(
128
+ data.length >= 35,
129
+ `Appended instruction ${i - originalInstructionCount + 1} has invalid SetAuthority data length`
130
+ )
131
+
132
+ const authorityType = data[1]
133
+ const hasNewAuthority = data[2]
134
+
135
+ assert(
136
+ authorityType === SPL_TOKEN_AUTHORITY_TYPE.CLOSE_ACCOUNT,
137
+ `Appended instruction ${i - originalInstructionCount + 1} is not for CloseAccount authority`
138
+ )
139
+
140
+ assert(
141
+ hasNewAuthority === 1,
142
+ `Appended instruction ${i - originalInstructionCount + 1} does not set a new authority`
143
+ )
144
+
145
+ assert(
146
+ instruction.accounts && instruction.accounts.length > 0,
147
+ `Appended instruction ${i - originalInstructionCount + 1} has no target account`
148
+ )
149
+
150
+ const targetAccountIndex = instruction.accounts[0]
151
+ const targetAccount = afterTx.message.accountKeys[targetAccountIndex].toString()
152
+
153
+ if (expectedProtectedAccounts.length > 0) {
154
+ // Find matching created account
155
+ const expectedAccount = expectedProtectedAccounts.find((exp) => {
156
+ return exp.account.toString() === targetAccount
157
+ })
158
+
159
+ assert(expectedAccount, `SetAuthority targets unexpected account: ${targetAccount}`)
160
+
161
+ // Verify using correct token program for this account
162
+ const expectedTokenProgram = expectedAccount.tokenProgram
163
+ assert(
164
+ programId.toString() === expectedTokenProgram.toString(),
165
+ `SetAuthority uses wrong token program for account ${targetAccount}`
166
+ )
167
+
168
+ assert(
169
+ !protectedAccountsMap.has(targetAccount),
170
+ `Duplicate SetAuthority for account ${targetAccount}`
171
+ )
172
+ protectedAccountsMap.set(targetAccount, true)
173
+ }
174
+ }
175
+
176
+ if (expectedProtectedAccounts.length > 0) {
177
+ assert(
178
+ protectedAccountsMap.size === expectedProtectedAccounts.length,
179
+ 'Not all created token accounts received SetAuthority protection'
180
+ )
181
+ }
182
+ }
183
+
184
+ if (afterTx.message.indexToProgramIds) {
185
+ afterTx.message.indexToProgramIds.forEach((value, key) => {
186
+ const accountKey = afterTx.message.accountKeys[key]
187
+ const matches = accountKey?.equals ? accountKey.equals(value) : accountKey === value
188
+ assert(matches, 'IndexToProgramIds do not match accountKeys')
189
+ })
190
+ }
103
191
 
104
192
  assert(
105
193
  lodash.isEqual(
@@ -148,3 +236,61 @@ export function verifyOnlyFeePayerChanged(beforeTx, afterTx) {
148
236
  'Transactions do not match in some attributes'
149
237
  )
150
238
  }
239
+
240
+ function isTokenProgram(programId) {
241
+ const programIdStr = programId.toString()
242
+ return (
243
+ programIdStr === TOKEN_PROGRAM_ID.toString() ||
244
+ programIdStr === TOKEN_2022_PROGRAM_ID.toString()
245
+ )
246
+ }
247
+
248
+ function isATAProgram(programId) {
249
+ const programIdStr = programId.toString()
250
+ return isTokenProgram(programId) || programIdStr === ASSOCIATED_TOKEN_PROGRAM_ID.toString()
251
+ }
252
+
253
+ function getTokenAccountCreations(transaction) {
254
+ const createdAccounts = []
255
+
256
+ transaction.message.instructions.forEach((instruction, index) => {
257
+ const programId = transaction.message.accountKeys[instruction.programIdIndex].toString()
258
+
259
+ // Check for ATA creation (Associated Token Account Program)
260
+ if (
261
+ programId === ASSOCIATED_TOKEN_PROGRAM_ID.toString() && // For ATA creation, the new account is typically at index 1
262
+ // Index 0 is payer, Index 1 is the ATA being created, Index 5 is the token program
263
+ instruction.accounts?.length >= 2
264
+ ) {
265
+ const ataIndex = instruction.accounts[1]
266
+ const tokenProgramIndex = instruction.accounts.length > 5 ? instruction.accounts[5] : null
267
+
268
+ createdAccounts.push({
269
+ accountIndex: ataIndex,
270
+ account: transaction.message.accountKeys[ataIndex],
271
+ tokenProgram: tokenProgramIndex
272
+ ? transaction.message.accountKeys[tokenProgramIndex]
273
+ : TOKEN_PROGRAM_ID,
274
+ })
275
+ }
276
+
277
+ // Check for InitializeAccount instruction (classic token account creation)
278
+ if (isTokenProgram(programId)) {
279
+ const data = instruction.data
280
+
281
+ if (
282
+ data?.length > 0 &&
283
+ data[0] === SPL_TOKEN_INSTRUCTION_TYPE.INITIALIZE_ACCOUNT &&
284
+ instruction.accounts?.length > 0
285
+ ) {
286
+ createdAccounts.push({
287
+ accountIndex: instruction.accounts[0],
288
+ account: transaction.message.accountKeys[instruction.accounts[0]],
289
+ tokenProgram: programId,
290
+ })
291
+ }
292
+ }
293
+ })
294
+
295
+ return createdAccounts
296
+ }
@@ -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 } from './token-program.js'
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Token Program instruction layouts
3
+ * Token-2022 (Token Extensions) is backward compatible with these instruction types
4
+ *
5
+ * Reference: https://github.com/solana-program/token/blob/81ba155af8684c224c943af16ac3d70f5cad5e93/pinocchio/interface/src/instruction.rs
6
+ */
7
+
8
+ /**
9
+ * An enumeration of valid SPL Token InstructionType's
10
+ */
11
+ export const TOKEN_INSTRUCTION_LAYOUTS = Object.freeze({
12
+ InitializeMint: {
13
+ index: 0,
14
+ },
15
+ InitializeAccount: {
16
+ index: 1,
17
+ },
18
+ InitializeMultisig: {
19
+ index: 2,
20
+ },
21
+ Transfer: {
22
+ index: 3,
23
+ // Accounts: [source, destination, authority]
24
+ },
25
+ Approve: {
26
+ index: 4,
27
+ },
28
+ Revoke: {
29
+ index: 5,
30
+ },
31
+ SetAuthority: {
32
+ index: 6,
33
+ },
34
+ MintTo: {
35
+ index: 7,
36
+ },
37
+ Burn: {
38
+ index: 8,
39
+ },
40
+ CloseAccount: {
41
+ index: 9,
42
+ },
43
+ FreezeAccount: {
44
+ index: 10,
45
+ },
46
+ ThawAccount: {
47
+ index: 11,
48
+ },
49
+ TransferChecked: {
50
+ index: 12,
51
+ // Accounts: [source, mint, destination, authority]
52
+ },
53
+ ApproveChecked: {
54
+ index: 13,
55
+ },
56
+ MintToChecked: {
57
+ index: 14,
58
+ },
59
+ BurnChecked: {
60
+ index: 15,
61
+ },
62
+ InitializeAccount2: {
63
+ index: 16,
64
+ },
65
+ SyncNative: {
66
+ index: 17,
67
+ },
68
+ InitializeAccount3: {
69
+ index: 18,
70
+ },
71
+ InitializeMultisig2: {
72
+ index: 19,
73
+ },
74
+ InitializeMint2: {
75
+ index: 20,
76
+ },
77
+ GetAccountDataSize: {
78
+ index: 21,
79
+ },
80
+ InitializeImmutableOwner: {
81
+ index: 22,
82
+ },
83
+ AmountToUiAmount: {
84
+ index: 23,
85
+ },
86
+ UiAmountToAmount: {
87
+ index: 24,
88
+ },
89
+ WithdrawExcessLamports: {
90
+ index: 38,
91
+ },
92
+ UnwrapLamports: {
93
+ index: 45,
94
+ },
95
+ Batch: {
96
+ index: 255,
97
+ },
98
+ })