@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 +20 -0
- package/package.json +2 -2
- package/src/constants.js +19 -0
- package/src/tx/parse-tx-buffer.js +220 -69
- package/src/tx/verify-only-fee-payer-changed.js +196 -50
- package/src/vendor/index.js +1 -0
- package/src/vendor/token-program.js +98 -0
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.
|
|
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": "
|
|
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 {
|
|
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(
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
26
|
+
throw new Error(`Unsupported instruction type: ${instructionType}`)
|
|
19
27
|
}
|
|
20
28
|
|
|
21
|
-
function decodeSystemTransferData(
|
|
22
|
-
const buffer =
|
|
29
|
+
function decodeSystemTransferData(data) {
|
|
30
|
+
const buffer = Buffer.from(data)
|
|
23
31
|
|
|
24
|
-
// 4 bytes
|
|
25
|
-
if (buffer.readUInt32LE(0) !==
|
|
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
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
if (!Array.isArray(instructions) || instructions.length !== 3) return false
|
|
50
|
+
const programId = accountKeys[instruction.programIdIndex]
|
|
45
51
|
|
|
46
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
if (data
|
|
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
|
|
85
|
+
export function isTokenTransfer(tx) {
|
|
69
86
|
const { message } = tx
|
|
70
87
|
const { accountKeys, instructions } = message
|
|
71
88
|
|
|
72
|
-
if (!Array.isArray(accountKeys) ||
|
|
73
|
-
if (!Array.isArray(instructions) || instructions.length !== 3) return false
|
|
89
|
+
if (!Array.isArray(accountKeys) || !Array.isArray(instructions)) return false
|
|
74
90
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
if (!accountKeys[ix.programIdIndex].equals(SYSTEM_PROGRAM_ID)) return false
|
|
101
|
+
if (!programId.equals(SYSTEM_PROGRAM_ID)) return false
|
|
84
102
|
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
if (data
|
|
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
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
269
|
+
if (parsedInstructions.length === 0) {
|
|
270
|
+
throw new Error('No supported instructions found in transaction')
|
|
271
|
+
}
|
|
123
272
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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('
|
|
283
|
+
console.log('Error getting mint address:', error)
|
|
133
284
|
}
|
|
134
285
|
|
|
135
|
-
|
|
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 {
|
|
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(
|
|
@@ -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
|
+
}
|
package/src/vendor/index.js
CHANGED
|
@@ -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
|
+
})
|