@exodus/solana-lib 3.15.2 → 3.16.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.16.0](https://github.com/ExodusMovement/assets/compare/@exodus/solana-lib@3.15.3...@exodus/solana-lib@3.16.0) (2025-11-11)
7
+
8
+
9
+ ### Features
10
+
11
+
12
+ * feat: Solana support Close Authority Sponsorship in fee payer (#6767)
13
+
14
+
15
+
16
+ ## [3.15.3](https://github.com/ExodusMovement/assets/compare/@exodus/solana-lib@3.15.1...@exodus/solana-lib@3.15.3) (2025-11-06)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+
22
+ * fix: overly strict transaction comparison in fee payer verification (#6785)
23
+
24
+
25
+
6
26
  ## [3.15.2](https://github.com/ExodusMovement/assets/compare/@exodus/solana-lib@3.15.1...@exodus/solana-lib@3.15.2) (2025-11-04)
7
27
 
8
28
  **Note:** Version bump only for package @exodus/solana-lib
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/solana-lib",
3
- "version": "3.15.2",
3
+ "version": "3.16.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": "c327da082bf6b55c270d4ab754c2abdc73e0a8de"
50
+ "gitHead": "e8b0a919196eda921b8bc532f5632180c2bef508"
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,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(
@@ -122,14 +210,24 @@ export function verifyOnlyFeePayerChanged(beforeTx, afterTx) {
122
210
  lodash.isEqual(
123
211
  {
124
212
  ...beforeTx.message,
125
- header: { ...beforeTx.message.header, numRequiredSignatures: null },
213
+ header: {
214
+ ...beforeTx.message.header,
215
+ numRequiredSignatures: null,
216
+ numReadonlySignedAccounts: null,
217
+ numReadonlyUnsignedAccounts: null,
218
+ },
126
219
  accountKeys: null,
127
220
  instructions: null,
128
221
  indexToProgramIds: null,
129
222
  },
130
223
  {
131
224
  ...afterTx.message,
132
- header: { ...afterTx.message.header, numRequiredSignatures: null },
225
+ header: {
226
+ ...afterTx.message.header,
227
+ numRequiredSignatures: null,
228
+ numReadonlySignedAccounts: null,
229
+ numReadonlyUnsignedAccounts: null,
230
+ },
133
231
  accountKeys: null,
134
232
  instructions: null,
135
233
  indexToProgramIds: null,
@@ -138,3 +236,61 @@ export function verifyOnlyFeePayerChanged(beforeTx, afterTx) {
138
236
  'Transactions do not match in some attributes'
139
237
  )
140
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
+ }