@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 +20 -0
- package/package.json +2 -2
- package/src/constants.js +19 -0
- package/src/tx/verify-only-fee-payer-changed.js +208 -52
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.
|
|
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": "
|
|
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 {
|
|
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
|
|
23
|
-
'
|
|
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
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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[
|
|
49
|
-
afterTx.message.accountKeys[
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
68
|
-
|
|
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
|
|
73
|
-
|
|
75
|
+
if (containsRentSysvar && isATAProgram(programId)) {
|
|
76
|
+
const adjustedBeforeAccounts = [...beforeAccounts]
|
|
77
|
+
adjustedBeforeAccounts[0] = afterTx.message.accountKeys[0]
|
|
74
78
|
assert(
|
|
75
|
-
lodash.isEqual(
|
|
76
|
-
'
|
|
79
|
+
lodash.isEqual(adjustedBeforeAccounts, afterAccounts),
|
|
80
|
+
'Instruction account keys were not updated correctly'
|
|
77
81
|
)
|
|
78
82
|
} else {
|
|
79
83
|
assert(
|
|
80
|
-
lodash.isEqual(
|
|
81
|
-
'
|
|
84
|
+
lodash.isEqual(beforeAccounts, afterAccounts),
|
|
85
|
+
'Instruction account keys were not updated correctly'
|
|
82
86
|
)
|
|
83
87
|
}
|
|
84
88
|
})
|
|
85
89
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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: {
|
|
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: {
|
|
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
|
+
}
|