@exodus/solana-api 3.25.4 → 3.26.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.
@@ -0,0 +1,533 @@
1
+ import lodash from 'lodash'
2
+
3
+ import {
4
+ isSolTransferInstruction,
5
+ isSplMintInstruction,
6
+ isSplTransferInstruction,
7
+ } from './txs-utils.js'
8
+
9
+ const ZERO = BigInt(0)
10
+
11
+ export const parseTransaction = (
12
+ ownerAddress,
13
+ txDetails,
14
+ tokenAccountsByOwner,
15
+ { includeUnparsed = false } = {}
16
+ ) => {
17
+ let { fee, preBalances, postBalances, preTokenBalances, postTokenBalances, innerInstructions } =
18
+ txDetails.meta
19
+ preBalances = preBalances || []
20
+ postBalances = postBalances || []
21
+ preTokenBalances = preTokenBalances || []
22
+ postTokenBalances = postTokenBalances || []
23
+ innerInstructions = innerInstructions || []
24
+
25
+ let { instructions, accountKeys = [] } = txDetails.transaction.message
26
+ const feePayerPubkey = accountKeys[0].pubkey
27
+ const ownerIsFeePayer = feePayerPubkey === ownerAddress
28
+ const txId = txDetails.transaction.signatures[0]
29
+
30
+ const getUnparsedTx = () => {
31
+ const ownerIndex = accountKeys.findIndex((accountKey) => accountKey.pubkey === ownerAddress)
32
+ const feePaid = ownerIndex === 0 ? fee : 0
33
+
34
+ return {
35
+ unparsed: true,
36
+ amount: ownerIndex === -1 ? 0 : postBalances[ownerIndex] - preBalances[ownerIndex] + feePaid,
37
+ fee: feePaid,
38
+ data: {
39
+ meta: txDetails.meta,
40
+ },
41
+ }
42
+ }
43
+
44
+ const getInnerTxsFromBalanceChanges = () => {
45
+ const ownPreTokenBalances = preTokenBalances.filter((balance) => balance.owner === ownerAddress)
46
+ const ownPostTokenBalances = postTokenBalances.filter(
47
+ (balance) => balance.owner === ownerAddress
48
+ )
49
+
50
+ return ownPostTokenBalances
51
+ .map((postBalance) => {
52
+ const tokenAccount = tokenAccountsByOwner.find(
53
+ (tokenAccount) => tokenAccount.mintAddress === postBalance.mint
54
+ )
55
+
56
+ const preBalance = ownPreTokenBalances.find(
57
+ (balance) => balance.accountIndex === postBalance.accountIndex
58
+ )
59
+
60
+ const preAmount = BigInt(preBalance?.uiTokenAmount?.amount ?? '0')
61
+ const postAmount = BigInt(postBalance?.uiTokenAmount?.amount ?? '0')
62
+
63
+ const amount = postAmount - preAmount
64
+
65
+ if (!tokenAccount || amount === ZERO) return null
66
+
67
+ // This is not perfect as there could be multiple same-token transfers in single
68
+ // transaction, but our wallet only supports one transaction with single txId
69
+ // so we are picking first that matches (correct token + type - send or receive)
70
+ const match = innerInstructions.find((inner) => {
71
+ const targetOwner = amount < ZERO ? ownerAddress : null
72
+ return inner.token.mintAddress === tokenAccount.mintAddress && targetOwner === inner.owner
73
+ })
74
+
75
+ // It's possible we won't find a match, because our innerInstructions only contain
76
+ // spl-token transfers, but balances of SPL tokens can change in different ways too.
77
+ // for now, we are ignoring this to simplify as those cases are not that common, but
78
+ // they should be handled eventually. It was already a scretch to add unparsed txs logic
79
+ // to existing parser, expanding it further is not going to end well.
80
+ // this probably should be refactored from ground to handle all those transactions
81
+ // as a core part of it in the future
82
+ if (!match) return null
83
+
84
+ const { from, to, owner } = match
85
+
86
+ return {
87
+ id: txId,
88
+ slot: txDetails.slot,
89
+ error: !(txDetails.meta.err === null),
90
+ owner,
91
+ from,
92
+ to,
93
+ amount: (amount < ZERO ? -amount : amount).toString(), // inconsistent with the rest, but it can and did overflow
94
+ fee: 0,
95
+ token: tokenAccount,
96
+ data: {
97
+ inner: true,
98
+ },
99
+ }
100
+ })
101
+ .filter((ix) => !!ix)
102
+ }
103
+
104
+ instructions = instructions
105
+ .filter((ix) => ix.parsed) // only known instructions
106
+ .map((ix) => ({
107
+ program: ix.program, // system or spl-token
108
+ type: ix.parsed.type, // transfer, createAccount, initializeAccount
109
+ ...ix.parsed.info,
110
+ }))
111
+
112
+ let solanaTransferTx = lodash.find(instructions, (ix) => {
113
+ if (![ix.source, ix.destination].includes(ownerAddress)) return false
114
+ return ix.program === 'system' && ix.type === 'transfer'
115
+ }) // get SOL transfer
116
+
117
+ // check if there is a temp account created & closed within the instructions when there is no direct solana transfer
118
+ const accountToRedeemToOwner = solanaTransferTx
119
+ ? undefined
120
+ : instructions.find(
121
+ ({ type, owner, destination }) =>
122
+ type === 'closeAccount' && owner === ownerAddress && destination === ownerAddress
123
+ )?.account
124
+
125
+ innerInstructions = innerInstructions
126
+ .reduce((acc, val) => {
127
+ return [...acc, ...val.instructions]
128
+ }, [])
129
+ .filter(
130
+ (ix) =>
131
+ ix.parsed &&
132
+ (isSplTransferInstruction({ program: ix.program, type: ix.parsed.type }) ||
133
+ isSplMintInstruction({ program: ix.program, type: ix.parsed.type }) ||
134
+ (!includeUnparsed &&
135
+ isSolTransferInstruction({ program: ix.program, type: ix.parsed.type })))
136
+ )
137
+ .map((ix) => {
138
+ let source = lodash.get(ix, 'parsed.info.source')
139
+ const destination = isSplMintInstruction({ program: ix.program, type: ix.parsed.type })
140
+ ? lodash.get(ix, 'parsed.info.account') // only for minting
141
+ : lodash.get(ix, 'parsed.info.destination')
142
+ const amount = Number(
143
+ lodash.get(ix, 'parsed.info.amount', 0) ||
144
+ lodash.get(ix, 'parsed.info.tokenAmount.amount', 0)
145
+ )
146
+ const authority = lodash.get(ix, 'parsed.info.authority')
147
+
148
+ if (accountToRedeemToOwner && destination === accountToRedeemToOwner) {
149
+ solanaTransferTx = {
150
+ from: authority || source,
151
+ to: ownerAddress,
152
+ amount,
153
+ fee,
154
+ }
155
+ return
156
+ }
157
+
158
+ if (
159
+ source === ownerAddress &&
160
+ isSolTransferInstruction({ program: ix.program, type: ix.parsed.type })
161
+ ) {
162
+ const lamports = Number(lodash.get(ix, 'parsed.info.lamports', 0))
163
+ if (solanaTransferTx) {
164
+ solanaTransferTx.lamports += lamports
165
+ solanaTransferTx.amount = solanaTransferTx.lamports
166
+ if (!Array.isArray(solanaTransferTx.to)) {
167
+ solanaTransferTx.data = {
168
+ sent: [
169
+ {
170
+ address: solanaTransferTx.to,
171
+ amount: lamports,
172
+ },
173
+ ],
174
+ }
175
+ solanaTransferTx.to = [solanaTransferTx.to]
176
+ }
177
+
178
+ solanaTransferTx.to.push(destination)
179
+ solanaTransferTx.data.sent.push({ address: destination, amount: lamports })
180
+ } else {
181
+ solanaTransferTx = {
182
+ source,
183
+ owner: source,
184
+ from: source,
185
+ to: [destination],
186
+ lamports,
187
+ amount: lamports,
188
+ data: {
189
+ sent: [
190
+ {
191
+ address: destination,
192
+ amount: lamports,
193
+ },
194
+ ],
195
+ },
196
+ fee,
197
+ }
198
+ }
199
+
200
+ return
201
+ }
202
+
203
+ const tokenAccount = tokenAccountsByOwner.find(({ tokenAccountAddress }) => {
204
+ return [source, destination].includes(tokenAccountAddress)
205
+ })
206
+ if (!tokenAccount) return
207
+
208
+ if (isSplMintInstruction({ program: ix.program, type: ix.parsed.type })) {
209
+ source = lodash.get(ix, 'parsed.info.mintAuthority')
210
+ }
211
+
212
+ const isSending = tokenAccountsByOwner.some(({ tokenAccountAddress }) => {
213
+ return [source].includes(tokenAccountAddress)
214
+ })
215
+
216
+ // owner if it's a send tx
217
+ return {
218
+ id: txId,
219
+ program: ix.program,
220
+ type: ix.parsed.type,
221
+ slot: txDetails.slot,
222
+ owner: isSending ? ownerAddress : null,
223
+ from: isSending ? ownerAddress : source,
224
+ to: isSending ? destination : ownerAddress,
225
+ amount,
226
+ token: tokenAccount,
227
+ // Attribute fee only when owner is the actual fee payer
228
+ fee: isSending && ownerIsFeePayer ? fee : 0,
229
+ }
230
+ })
231
+ .filter((ix) => !!ix)
232
+
233
+ // Collect inner instructions into batch sends
234
+ for (let i = 0; i < innerInstructions.length - 1; i++) {
235
+ const tx = innerInstructions[i]
236
+
237
+ for (let j = i + 1; j < innerInstructions.length; j++) {
238
+ const next = innerInstructions[j]
239
+ if (
240
+ tx.id === next.id &&
241
+ tx.token === next.token &&
242
+ tx.owner === ownerAddress &&
243
+ tx.from === next.from
244
+ ) {
245
+ if (!tx.data) {
246
+ tx.data = { sent: [{ address: tx.to, amount: tx.amount }] }
247
+ tx.to = [tx.to]
248
+ tx.fee = 0
249
+ }
250
+
251
+ tx.data.sent.push({
252
+ address: next.to,
253
+ amount: next.amount,
254
+ })
255
+ tx.to.push(next.to)
256
+
257
+ tx.amount += next.amount
258
+
259
+ innerInstructions.splice(j, 1)
260
+ j--
261
+ }
262
+ }
263
+ }
264
+
265
+ // program:type tells us if it's a SOL or Token transfer
266
+
267
+ const stakeTx = lodash.find(instructions, { program: 'system', type: 'createAccountWithSeed' })
268
+ const stakeWithdraw = lodash.find(instructions, { program: 'stake', type: 'withdraw' })
269
+ const stakeUndelegate = lodash.find(instructions, { program: 'stake', type: 'deactivate' })
270
+
271
+ let tx = {}
272
+ if (stakeTx) {
273
+ // start staking
274
+ tx = {
275
+ owner: stakeTx.base,
276
+ from: stakeTx.base,
277
+ to: stakeTx.base,
278
+ amount: stakeTx.lamports,
279
+ fee,
280
+ staking: {
281
+ method: 'createAccountWithSeed',
282
+ seed: stakeTx.seed,
283
+ stakeAddresses: [stakeTx.newAccount],
284
+ stake: stakeTx.lamports,
285
+ },
286
+ }
287
+ } else if (stakeWithdraw) {
288
+ const stakeAccounts = lodash.map(
289
+ lodash.filter(instructions, { program: 'stake', type: 'withdraw' }),
290
+ 'stakeAccount'
291
+ )
292
+ tx = {
293
+ owner: stakeWithdraw.withdrawAuthority,
294
+ from: stakeWithdraw.stakeAccount,
295
+ to: stakeWithdraw.destination,
296
+ amount: stakeWithdraw.lamports,
297
+ fee,
298
+ staking: {
299
+ method: 'withdraw',
300
+ stakeAddresses: stakeAccounts,
301
+ stake: stakeWithdraw.lamports,
302
+ },
303
+ }
304
+ } else if (stakeUndelegate) {
305
+ const stakeAccounts = lodash.map(
306
+ lodash.filter(instructions, { program: 'stake', type: 'deactivate' }),
307
+ 'stakeAccount'
308
+ )
309
+ tx = {
310
+ owner: stakeUndelegate.stakeAuthority,
311
+ from: stakeUndelegate.stakeAuthority,
312
+ to: stakeUndelegate.stakeAccount, // obsolete
313
+ amount: 0,
314
+ fee,
315
+ staking: {
316
+ method: 'undelegate',
317
+ stakeAddresses: stakeAccounts,
318
+ },
319
+ }
320
+ } else {
321
+ if (solanaTransferTx) {
322
+ const isSending = ownerAddress === solanaTransferTx.source
323
+ tx = {
324
+ owner: solanaTransferTx.source,
325
+ from: solanaTransferTx.source,
326
+ to: solanaTransferTx.destination,
327
+ amount: solanaTransferTx.lamports, // number
328
+ fee: isSending && ownerIsFeePayer ? fee : 0,
329
+ data: solanaTransferTx.data,
330
+ }
331
+ }
332
+
333
+ const accountIndexes = accountKeys.reduce((acc, key, i) => {
334
+ const hasKnownOwner = tokenAccountsByOwner.some(
335
+ (tokenAccount) => tokenAccount.tokenAccountAddress === key.pubkey
336
+ )
337
+
338
+ acc[i] = {
339
+ ...key,
340
+ owner: hasKnownOwner ? ownerAddress : null, // not know (like in an outgoing tx)
341
+ }
342
+
343
+ return acc
344
+ }, Object.create(null)) // { 0: { pubkey, owner }, 1: { ... }, ... }
345
+
346
+ // Parse Token txs
347
+ const tokenTxs = _parseTokenTransfers({
348
+ instructions,
349
+ innerInstructions,
350
+ tokenAccountsByOwner,
351
+ ownerAddress,
352
+ fee: ownerIsFeePayer ? fee : 0,
353
+ accountIndexes,
354
+ preTokenBalances,
355
+ postTokenBalances,
356
+ })
357
+
358
+ if (tokenTxs.length > 0) {
359
+ // found spl-token simple transfer/transferChecked instruction
360
+ tx.tokenTxs = tokenTxs.map((tx) => ({
361
+ id: txId,
362
+ slot: txDetails.slot,
363
+ error: !(txDetails.meta.err === null),
364
+ ...tx,
365
+ }))
366
+ } else if (preTokenBalances && postTokenBalances) {
367
+ // probably a DEX program is involved (multiple instructions), compute balance changes
368
+ // group by owner and supported token
369
+ const preBalances = preTokenBalances.filter((t) => {
370
+ return accountIndexes[t.accountIndex].owner === ownerAddress
371
+ })
372
+ const postBalances = postTokenBalances.filter((t) => {
373
+ return accountIndexes[t.accountIndex].owner === ownerAddress
374
+ })
375
+
376
+ if (preBalances.length > 0 || postBalances.length > 0 || solanaTransferTx) {
377
+ tx = {}
378
+
379
+ if (includeUnparsed && innerInstructions.length > 0) {
380
+ // when using includeUnparsed for DEX tx we want to keep SOL tx as "unparsed"
381
+ // 1. we want to treat all SOL dex transactions as "Contract transaction", not "Sent SOL"
382
+ // 2. default behavior is not perfect. For example it doesn't see SOL-side tx in
383
+ // SOL->SPL swaps on Raydium and Orca.
384
+ tx = getUnparsedTx()
385
+ tx.dexTxs = getInnerTxsFromBalanceChanges()
386
+ } else {
387
+ if (solanaTransferTx) {
388
+ // the base tx will be the one that moved solana.
389
+ tx =
390
+ solanaTransferTx.from && solanaTransferTx.to
391
+ ? solanaTransferTx
392
+ : {
393
+ owner: solanaTransferTx.source,
394
+ from: solanaTransferTx.source,
395
+ to: solanaTransferTx.destination,
396
+ amount: solanaTransferTx.lamports, // number
397
+ fee: ownerAddress === solanaTransferTx.source ? fee : 0,
398
+ }
399
+ }
400
+
401
+ // If it has inner instructions then it's a DEX tx that moved SPL -> SPL
402
+ if (innerInstructions.length > 0) {
403
+ tx.dexTxs = innerInstructions
404
+ // if tx involves only SPL swaps. Expand DEX ix (first element as tx base and the other kept there)
405
+ if (!tx.from && !solanaTransferTx) {
406
+ tx = tx.dexTxs[0]
407
+ tx.dexTxs = innerInstructions.slice(1)
408
+ }
409
+ }
410
+ }
411
+ }
412
+ }
413
+ }
414
+
415
+ const unparsed = Object.keys(tx).length === 0
416
+
417
+ if (unparsed && includeUnparsed) {
418
+ tx = getUnparsedTx()
419
+ }
420
+
421
+ // How tokens tx are parsed:
422
+ // 0. compute incoming or outgoing tx: it's outgoing if spl-token:transfer has source/destination included in tokenAccountsByOwner
423
+ // 1. if it's a sent tx: sum all instructions amount (spl-token:transfer)
424
+ // 2. if it's an incoming tx: sum all the amounts with destination included in tokenAccountsByOwner (aggregating by ticker)
425
+ // QUESTION: How do I know what are my tokens addresses deterministically? It's not possible, gotta use tokenAccountsByOwner
426
+
427
+ return {
428
+ id: txId,
429
+ slot: txDetails.slot,
430
+ error: !(txDetails.meta.err === null),
431
+ ...tx,
432
+ }
433
+ }
434
+
435
+ function _parseTokenTransfers({
436
+ instructions,
437
+ innerInstructions = [],
438
+ tokenAccountsByOwner,
439
+ ownerAddress,
440
+ fee,
441
+ accountIndexes = {},
442
+ preTokenBalances,
443
+ postTokenBalances,
444
+ }) {
445
+ if (
446
+ preTokenBalances.length === 0 &&
447
+ postTokenBalances.length === 0 &&
448
+ !Array.isArray(tokenAccountsByOwner)
449
+ ) {
450
+ return []
451
+ }
452
+
453
+ const tokenTxs = []
454
+
455
+ instructions.forEach((instruction) => {
456
+ const { type, program, source, destination, amount, tokenAmount } = instruction
457
+
458
+ if (isSplTransferInstruction({ program, type })) {
459
+ let tokenAccount = lodash.find(tokenAccountsByOwner, { tokenAccountAddress: source })
460
+ const isSending = !!tokenAccount
461
+ if (!isSending) {
462
+ // receiving
463
+ tokenAccount = lodash.find(tokenAccountsByOwner, {
464
+ tokenAccountAddress: destination,
465
+ })
466
+ }
467
+
468
+ if (!tokenAccount) return // no transfers with our addresses involved
469
+
470
+ const owner = isSending ? ownerAddress : null
471
+
472
+ delete tokenAccount.balance
473
+ delete tokenAccount.owner
474
+
475
+ // If it's a sending tx we want to have the destination's owner as "to" address
476
+ let to = ownerAddress
477
+ let from = ownerAddress
478
+ if (isSending) {
479
+ to = destination // token account address (trying to get the owner below, we don't always have postTokenBalances...)
480
+ postTokenBalances.forEach((t) => {
481
+ if (accountIndexes[t.accountIndex].pubkey === destination) to = t.owner
482
+ })
483
+ } else {
484
+ // is receiving tx
485
+ from = source // token account address
486
+ preTokenBalances.forEach((t) => {
487
+ if (accountIndexes[t.accountIndex].pubkey === source) from = t.owner
488
+ })
489
+ }
490
+
491
+ tokenTxs.push({
492
+ owner,
493
+ token: tokenAccount,
494
+ from,
495
+ to,
496
+ amount: String(amount || tokenAmount?.amount || '0'), // supporting types: transfer, transferChecked, transferCheckedWithFee
497
+ fee: isSending ? fee : 0, // in lamports
498
+ })
499
+ }
500
+ })
501
+
502
+ innerInstructions.forEach((parsedIx) => {
503
+ const { type, program, amount, from, to } = parsedIx
504
+
505
+ // Handle token minting (mintTo, mintToChecked)
506
+ if (isSplMintInstruction({ program, type })) {
507
+ const {
508
+ token: { tokenAccountAddress },
509
+ } = parsedIx
510
+
511
+ // Check if the destination token account belongs to our owner
512
+ const tokenAccount = lodash.find(tokenAccountsByOwner, {
513
+ tokenAccountAddress,
514
+ })
515
+
516
+ if (!tokenAccount) return // not our token account
517
+
518
+ delete tokenAccount.balance
519
+ delete tokenAccount.owner
520
+
521
+ tokenTxs.push({
522
+ owner: null, // no owner for minting (it's created from thin air)
523
+ token: tokenAccount,
524
+ from, // mint address as the source
525
+ to, // our address as recipient
526
+ amount: String(amount || 0),
527
+ fee: 0, // no fee for receiving minted tokens
528
+ })
529
+ }
530
+ })
531
+
532
+ return tokenTxs
533
+ }