@exodus/solana-api 2.0.4 → 2.0.5
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/package.json +8 -4
- package/src/account-state.js +32 -0
- package/src/api.js +863 -0
- package/src/index.js +3 -863
- package/src/tx-log/index.js +1 -0
- package/src/tx-log/solana-monitor.js +189 -0
package/src/index.js
CHANGED
|
@@ -1,863 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
getMetadataAccount,
|
|
5
|
-
deserializeMetaplexMetadata,
|
|
6
|
-
getTransactionSimulationParams,
|
|
7
|
-
filterAccountsByOwner,
|
|
8
|
-
SYSTEM_PROGRAM_ID,
|
|
9
|
-
STAKE_PROGRAM_ID,
|
|
10
|
-
TOKEN_PROGRAM_ID,
|
|
11
|
-
SOL_DECIMAL,
|
|
12
|
-
computeBalance,
|
|
13
|
-
SolanaWeb3Message,
|
|
14
|
-
buildRawTransaction,
|
|
15
|
-
} from '@exodus/solana-lib'
|
|
16
|
-
import assets from '@exodus/assets'
|
|
17
|
-
import assert from 'assert'
|
|
18
|
-
import lodash from 'lodash'
|
|
19
|
-
import urljoin from 'url-join'
|
|
20
|
-
import wretch, { Wretcher } from 'wretch'
|
|
21
|
-
import { magicEden } from '@exodus/nfts-core'
|
|
22
|
-
|
|
23
|
-
// Doc: https://docs.solana.com/apps/jsonrpc-api
|
|
24
|
-
|
|
25
|
-
const RPC_URL = 'https://solana.a.exodus.io' // https://vip-api.mainnet-beta.solana.com/, https://api.mainnet-beta.solana.com, https://solana-api.projectserum.com
|
|
26
|
-
|
|
27
|
-
// Tokens + SOL api support
|
|
28
|
-
export class Api {
|
|
29
|
-
constructor(rpcUrl) {
|
|
30
|
-
this.setServer(rpcUrl)
|
|
31
|
-
this.setTokens(assets)
|
|
32
|
-
this.tokensToSkip = {}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
setServer(rpcUrl) {
|
|
36
|
-
this.rpcUrl = rpcUrl || RPC_URL
|
|
37
|
-
this.api = createApi(this.rpcUrl)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
setTokens(assets = {}) {
|
|
41
|
-
const solTokens = lodash.pickBy(assets, (asset) => asset.assetType === 'SOLANA_TOKEN')
|
|
42
|
-
this.tokens = lodash.mapKeys(solTokens, (v) => v.mintAddress)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
request(path, contentType = 'application/json'): Wretcher {
|
|
46
|
-
return wretch(urljoin(this.rpcUrl, path)).headers({
|
|
47
|
-
'Content-type': contentType,
|
|
48
|
-
})
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
isTokenSupported(mint: string) {
|
|
52
|
-
return !!this.tokens[mint]
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async getEpochInfo(): number {
|
|
56
|
-
const { epoch } = await this.api.post({
|
|
57
|
-
method: 'getEpochInfo',
|
|
58
|
-
})
|
|
59
|
-
return Number(epoch)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
async getStakeActivation(address): string {
|
|
63
|
-
const { state } = await this.api.post({
|
|
64
|
-
method: 'getStakeActivation',
|
|
65
|
-
params: [address],
|
|
66
|
-
})
|
|
67
|
-
return state
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
async getRecentBlockHash(): string {
|
|
71
|
-
const {
|
|
72
|
-
value: { blockhash },
|
|
73
|
-
} = await this.api.post({
|
|
74
|
-
method: 'getRecentBlockhash',
|
|
75
|
-
})
|
|
76
|
-
return blockhash
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Transaction structure: https://docs.solana.com/apps/jsonrpc-api#transaction-structure
|
|
80
|
-
async getTransactionById(id: string) {
|
|
81
|
-
const result = await this.api.post({
|
|
82
|
-
method: 'getConfirmedTransaction',
|
|
83
|
-
params: [id, 'jsonParsed'],
|
|
84
|
-
})
|
|
85
|
-
return result
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async getFee(): number {
|
|
89
|
-
const {
|
|
90
|
-
value: {
|
|
91
|
-
feeCalculator: { lamportsPerSignature },
|
|
92
|
-
},
|
|
93
|
-
} = await this.api.post({
|
|
94
|
-
method: 'getRecentBlockhash',
|
|
95
|
-
})
|
|
96
|
-
return lamportsPerSignature
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async getBalance(address: string): number {
|
|
100
|
-
const res = await this.api.post({
|
|
101
|
-
method: 'getBalance',
|
|
102
|
-
params: [address],
|
|
103
|
-
})
|
|
104
|
-
return res.value || 0
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async getBlockTime(slot: number) {
|
|
108
|
-
// might result in error if executed on a validator with partial ledger (https://github.com/solana-labs/solana/issues/12413)
|
|
109
|
-
return this.api.post({
|
|
110
|
-
method: 'getBlockTime',
|
|
111
|
-
params: [slot],
|
|
112
|
-
})
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
async getConfirmedSignaturesForAddress(address: string, { until, before, limit } = {}): any {
|
|
116
|
-
until = until || undefined
|
|
117
|
-
return this.api.post({
|
|
118
|
-
method: 'getSignaturesForAddress',
|
|
119
|
-
params: [address, { until, before, limit }],
|
|
120
|
-
})
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Get transactions from an address
|
|
125
|
-
*/
|
|
126
|
-
async getTransactions(
|
|
127
|
-
address: string,
|
|
128
|
-
{ cursor, before, limit, includeUnparsed = false } = {}
|
|
129
|
-
): any {
|
|
130
|
-
let transactions = []
|
|
131
|
-
// cursor is a txHash
|
|
132
|
-
|
|
133
|
-
try {
|
|
134
|
-
let until = cursor
|
|
135
|
-
|
|
136
|
-
const tokenAccountsByOwner = await this.getTokenAccountsByOwner(address) // Array
|
|
137
|
-
const tokenAccountAddresses = tokenAccountsByOwner
|
|
138
|
-
.filter(({ tokenName }) => tokenName !== 'unknown')
|
|
139
|
-
.map(({ tokenAccountAddress }) => tokenAccountAddress)
|
|
140
|
-
const accountsToCheck = [address, ...tokenAccountAddresses]
|
|
141
|
-
|
|
142
|
-
const txsResultsByAccount = await Promise.all(
|
|
143
|
-
accountsToCheck.map((addr) =>
|
|
144
|
-
this.getConfirmedSignaturesForAddress(addr, {
|
|
145
|
-
until,
|
|
146
|
-
before,
|
|
147
|
-
limit,
|
|
148
|
-
})
|
|
149
|
-
)
|
|
150
|
-
)
|
|
151
|
-
let txsId = txsResultsByAccount.reduce((arr, row) => arr.concat(row), []) // merge arrays
|
|
152
|
-
txsId = lodash.uniqBy(txsId, 'signature')
|
|
153
|
-
|
|
154
|
-
// get txs details in parallel
|
|
155
|
-
const txsDetails = await Promise.all(txsId.map((tx) => this.getTransactionById(tx.signature)))
|
|
156
|
-
txsDetails.forEach((txDetail) => {
|
|
157
|
-
if (txDetail === null) return
|
|
158
|
-
|
|
159
|
-
const timestamp = txDetail.blockTime * 1000
|
|
160
|
-
const parsedTx = this.parseTransaction(address, txDetail, tokenAccountsByOwner, {
|
|
161
|
-
includeUnparsed,
|
|
162
|
-
})
|
|
163
|
-
if (!parsedTx.from && !includeUnparsed) return // cannot parse it
|
|
164
|
-
|
|
165
|
-
// split dexTx in separate txs
|
|
166
|
-
if (parsedTx.dexTxs) {
|
|
167
|
-
parsedTx.dexTxs.forEach((tx) => {
|
|
168
|
-
transactions.push({
|
|
169
|
-
timestamp,
|
|
170
|
-
date: new Date(timestamp),
|
|
171
|
-
...tx,
|
|
172
|
-
})
|
|
173
|
-
})
|
|
174
|
-
delete parsedTx.dexTxs
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
transactions.push({
|
|
178
|
-
timestamp,
|
|
179
|
-
date: new Date(timestamp),
|
|
180
|
-
...parsedTx,
|
|
181
|
-
})
|
|
182
|
-
})
|
|
183
|
-
} catch (err) {
|
|
184
|
-
console.warn('Solana error:', err)
|
|
185
|
-
throw err
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
transactions = lodash.orderBy(transactions, ['timestamp'], ['desc'])
|
|
189
|
-
|
|
190
|
-
const newCursor = transactions[0] ? transactions[0].id : cursor
|
|
191
|
-
|
|
192
|
-
return { transactions, newCursor }
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
parseTransaction(
|
|
196
|
-
ownerAddress: string,
|
|
197
|
-
txDetails: Object,
|
|
198
|
-
tokenAccountsByOwner: ?Array,
|
|
199
|
-
{ includeUnparsed = false } = {}
|
|
200
|
-
): Object {
|
|
201
|
-
let {
|
|
202
|
-
fee,
|
|
203
|
-
preBalances,
|
|
204
|
-
postBalances,
|
|
205
|
-
preTokenBalances,
|
|
206
|
-
postTokenBalances,
|
|
207
|
-
innerInstructions,
|
|
208
|
-
} = txDetails.meta
|
|
209
|
-
preBalances = preBalances || []
|
|
210
|
-
postBalances = postBalances || []
|
|
211
|
-
preTokenBalances = preTokenBalances || []
|
|
212
|
-
postTokenBalances = postTokenBalances || []
|
|
213
|
-
innerInstructions = innerInstructions || []
|
|
214
|
-
|
|
215
|
-
let { instructions, accountKeys } = txDetails.transaction.message
|
|
216
|
-
|
|
217
|
-
const getUnparsedTx = () => {
|
|
218
|
-
const ownerIndex = accountKeys.findIndex((accountKey) => accountKey.pubkey === ownerAddress)
|
|
219
|
-
const feePaid = ownerIndex === 0 ? fee : 0
|
|
220
|
-
|
|
221
|
-
return {
|
|
222
|
-
unparsed: true,
|
|
223
|
-
amount: postBalances[ownerIndex] - preBalances[ownerIndex] + feePaid,
|
|
224
|
-
fee: feePaid,
|
|
225
|
-
data: {
|
|
226
|
-
meta: txDetails.meta,
|
|
227
|
-
},
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
instructions = instructions
|
|
232
|
-
.filter((ix) => ix.parsed) // only known instructions
|
|
233
|
-
.map((ix) => ({
|
|
234
|
-
program: ix.program, // system or spl-token
|
|
235
|
-
type: ix.parsed.type, // transfer, createAccount, initializeAccount
|
|
236
|
-
...ix.parsed.info,
|
|
237
|
-
}))
|
|
238
|
-
innerInstructions = innerInstructions
|
|
239
|
-
.reduce((acc, val) => {
|
|
240
|
-
return acc.concat(val.instructions)
|
|
241
|
-
}, [])
|
|
242
|
-
.map((ix) => {
|
|
243
|
-
const type = lodash.get(ix, 'parsed.type')
|
|
244
|
-
const isTransferTx = ix.parsed && ix.program === 'spl-token' && type === 'transfer'
|
|
245
|
-
const source = lodash.get(ix, 'parsed.info.source')
|
|
246
|
-
const destination = lodash.get(ix, 'parsed.info.destination')
|
|
247
|
-
const amount = Number(lodash.get(ix, 'parsed.info.amount', 0))
|
|
248
|
-
|
|
249
|
-
const tokenAccount = tokenAccountsByOwner.find(({ tokenAccountAddress }) => {
|
|
250
|
-
return [source, destination].includes(tokenAccountAddress)
|
|
251
|
-
})
|
|
252
|
-
const isSending = !!tokenAccountsByOwner.find(({ tokenAccountAddress }) => {
|
|
253
|
-
return [source].includes(tokenAccountAddress)
|
|
254
|
-
})
|
|
255
|
-
|
|
256
|
-
// owner if it's a send tx
|
|
257
|
-
const instruction = {
|
|
258
|
-
id: txDetails.transaction.signatures[0],
|
|
259
|
-
slot: txDetails.slot,
|
|
260
|
-
owner: isSending ? ownerAddress : null,
|
|
261
|
-
from: source,
|
|
262
|
-
to: destination,
|
|
263
|
-
amount,
|
|
264
|
-
token: tokenAccount,
|
|
265
|
-
fee: isSending ? fee : 0,
|
|
266
|
-
}
|
|
267
|
-
return isTransferTx && tokenAccount ? instruction : null
|
|
268
|
-
})
|
|
269
|
-
.filter((ix) => !!ix)
|
|
270
|
-
|
|
271
|
-
// program:type tells us if it's a SOL or Token transfer
|
|
272
|
-
const solanaTx = lodash.find(instructions, (ix) => {
|
|
273
|
-
if (![ix.source, ix.destination].includes(ownerAddress)) return false
|
|
274
|
-
return ix.program === 'system' && ix.type === 'transfer'
|
|
275
|
-
}) // get SOL transfer
|
|
276
|
-
const stakeTx = lodash.find(instructions, { program: 'system', type: 'createAccountWithSeed' })
|
|
277
|
-
const stakeWithdraw = lodash.find(instructions, { program: 'stake', type: 'withdraw' })
|
|
278
|
-
const stakeUndelegate = lodash.find(instructions, { program: 'stake', type: 'deactivate' })
|
|
279
|
-
const hasSolanaTx = solanaTx && !preTokenBalances.length && !postTokenBalances.length // only SOL moved and no tokens movements
|
|
280
|
-
|
|
281
|
-
let tx = {}
|
|
282
|
-
if (hasSolanaTx) {
|
|
283
|
-
// Solana tx
|
|
284
|
-
const isSending = ownerAddress === solanaTx.source
|
|
285
|
-
tx = {
|
|
286
|
-
owner: solanaTx.source,
|
|
287
|
-
from: solanaTx.source,
|
|
288
|
-
to: solanaTx.destination,
|
|
289
|
-
amount: solanaTx.lamports, // number
|
|
290
|
-
fee: isSending ? fee : 0,
|
|
291
|
-
}
|
|
292
|
-
} else if (stakeTx) {
|
|
293
|
-
// start staking
|
|
294
|
-
tx = {
|
|
295
|
-
owner: stakeTx.base,
|
|
296
|
-
from: stakeTx.base,
|
|
297
|
-
to: stakeTx.base,
|
|
298
|
-
amount: stakeTx.lamports,
|
|
299
|
-
fee,
|
|
300
|
-
staking: {
|
|
301
|
-
method: 'createAccountWithSeed',
|
|
302
|
-
seed: stakeTx.seed,
|
|
303
|
-
stakeAddresses: [stakeTx.newAccount],
|
|
304
|
-
stake: stakeTx.lamports,
|
|
305
|
-
},
|
|
306
|
-
}
|
|
307
|
-
} else if (stakeWithdraw) {
|
|
308
|
-
const stakeAccounts = lodash.map(
|
|
309
|
-
lodash.filter(instructions, { program: 'stake', type: 'withdraw' }),
|
|
310
|
-
'stakeAccount'
|
|
311
|
-
)
|
|
312
|
-
tx = {
|
|
313
|
-
owner: stakeWithdraw.withdrawAuthority,
|
|
314
|
-
from: stakeWithdraw.stakeAccount,
|
|
315
|
-
to: stakeWithdraw.destination,
|
|
316
|
-
amount: stakeWithdraw.lamports,
|
|
317
|
-
fee,
|
|
318
|
-
staking: {
|
|
319
|
-
method: 'withdraw',
|
|
320
|
-
stakeAddresses: stakeAccounts,
|
|
321
|
-
stake: stakeWithdraw.lamports,
|
|
322
|
-
},
|
|
323
|
-
}
|
|
324
|
-
} else if (stakeUndelegate) {
|
|
325
|
-
const stakeAccounts = lodash.map(
|
|
326
|
-
lodash.filter(instructions, { program: 'stake', type: 'deactivate' }),
|
|
327
|
-
'stakeAccount'
|
|
328
|
-
)
|
|
329
|
-
tx = {
|
|
330
|
-
owner: stakeUndelegate.stakeAuthority,
|
|
331
|
-
from: stakeUndelegate.stakeAuthority,
|
|
332
|
-
to: stakeUndelegate.stakeAccount, // obsolete
|
|
333
|
-
amount: 0,
|
|
334
|
-
fee,
|
|
335
|
-
staking: {
|
|
336
|
-
method: 'undelegate',
|
|
337
|
-
stakeAddresses: stakeAccounts,
|
|
338
|
-
},
|
|
339
|
-
}
|
|
340
|
-
} else {
|
|
341
|
-
// Token tx
|
|
342
|
-
assert.ok(
|
|
343
|
-
Array.isArray(tokenAccountsByOwner),
|
|
344
|
-
'tokenAccountsByOwner is required when parsing token tx'
|
|
345
|
-
)
|
|
346
|
-
let tokenTxs = lodash
|
|
347
|
-
.filter(instructions, ({ program, type }) => {
|
|
348
|
-
return program === 'spl-token' && ['transfer', 'transferChecked'].includes(type)
|
|
349
|
-
}) // get Token transfer: could have more than 1 instructions
|
|
350
|
-
.map((ix) => {
|
|
351
|
-
// add token details based on source/destination address
|
|
352
|
-
let tokenAccount = lodash.find(tokenAccountsByOwner, { tokenAccountAddress: ix.source })
|
|
353
|
-
const isSending = !!tokenAccount
|
|
354
|
-
if (!isSending)
|
|
355
|
-
tokenAccount = lodash.find(tokenAccountsByOwner, {
|
|
356
|
-
tokenAccountAddress: ix.destination,
|
|
357
|
-
}) // receiving
|
|
358
|
-
if (!tokenAccount) return null // no transfers with our addresses involved
|
|
359
|
-
const owner = isSending ? ownerAddress : null
|
|
360
|
-
|
|
361
|
-
delete tokenAccount.balance
|
|
362
|
-
delete tokenAccount.owner
|
|
363
|
-
return {
|
|
364
|
-
owner,
|
|
365
|
-
token: tokenAccount,
|
|
366
|
-
from: ix.source,
|
|
367
|
-
to: ix.destination,
|
|
368
|
-
amount: Number(ix.amount || lodash.get(ix, 'tokenAmount.amount', 0)), // supporting both types: transfer and transferChecked
|
|
369
|
-
fee: isSending ? fee : 0, // in lamports
|
|
370
|
-
}
|
|
371
|
-
})
|
|
372
|
-
|
|
373
|
-
if (tokenTxs.length) {
|
|
374
|
-
// found spl-token simple transfer/transferChecked instruction
|
|
375
|
-
// .reduce to sum/sub (based on isSending) all the same tokens amount (From instructions -> 1 single tx)
|
|
376
|
-
tx = tokenTxs.reduce((finalTx, ix) => {
|
|
377
|
-
if (!ix) return finalTx // skip null instructions
|
|
378
|
-
if (!finalTx.token) return ix // init finalTx (support just 1 token type per tx)
|
|
379
|
-
if (finalTx.token.ticker === ix.token.ticker) finalTx.amount += ix.amount
|
|
380
|
-
return finalTx
|
|
381
|
-
}, {})
|
|
382
|
-
} else if (preTokenBalances && postTokenBalances) {
|
|
383
|
-
// probably a DEX program is involved (multiple instructions), compute balance changes
|
|
384
|
-
|
|
385
|
-
const accountIndexes = lodash.mapKeys(accountKeys, (x, i) => i)
|
|
386
|
-
Object.values(accountIndexes).forEach((acc) => {
|
|
387
|
-
// filter by ownerAddress
|
|
388
|
-
const hasKnownOwner = !!lodash.find(tokenAccountsByOwner, {
|
|
389
|
-
tokenAccountAddress: acc.pubkey,
|
|
390
|
-
})
|
|
391
|
-
acc.owner = hasKnownOwner ? ownerAddress : null
|
|
392
|
-
})
|
|
393
|
-
|
|
394
|
-
// group by owner and supported token
|
|
395
|
-
const preBalances = preTokenBalances.filter((t) => {
|
|
396
|
-
return (
|
|
397
|
-
accountIndexes[t.accountIndex].owner === ownerAddress && this.isTokenSupported(t.mint)
|
|
398
|
-
)
|
|
399
|
-
})
|
|
400
|
-
const postBalances = postTokenBalances.filter((t) => {
|
|
401
|
-
return (
|
|
402
|
-
accountIndexes[t.accountIndex].owner === ownerAddress && this.isTokenSupported(t.mint)
|
|
403
|
-
)
|
|
404
|
-
})
|
|
405
|
-
|
|
406
|
-
if (preBalances.length || postBalances.length) {
|
|
407
|
-
tx = {}
|
|
408
|
-
|
|
409
|
-
if (includeUnparsed && innerInstructions.length) {
|
|
410
|
-
// when using includeUnparsed for DEX tx we want to keep SOL tx as "unparsed"
|
|
411
|
-
// 1. we want to treat all SOL dex transactions as "Contract transaction", not "Sent SOL"
|
|
412
|
-
// 2. default behavior is not perfect. For example it doesn't see SOL-side tx in
|
|
413
|
-
// SOL->SPL swaps on Raydium and Orca.
|
|
414
|
-
tx = getUnparsedTx(tx)
|
|
415
|
-
tx.dexTxs = innerInstructions.map((i) => ({ ...i, fee: 0 }))
|
|
416
|
-
} else {
|
|
417
|
-
if (solanaTx) {
|
|
418
|
-
// the base tx will be the one that moved solana.
|
|
419
|
-
tx = {
|
|
420
|
-
owner: solanaTx.source,
|
|
421
|
-
from: solanaTx.source,
|
|
422
|
-
to: solanaTx.destination,
|
|
423
|
-
amount: solanaTx.lamports, // number
|
|
424
|
-
fee: ownerAddress === solanaTx.source ? fee : 0,
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// If it has inner instructions then it's a DEX tx that moved SPL -> SPL
|
|
429
|
-
if (innerInstructions.length) {
|
|
430
|
-
tx.dexTxs = innerInstructions
|
|
431
|
-
// if tx involves only SPL swaps. Expand DEX ix (first element as tx base and the other kept there)
|
|
432
|
-
if (!tx.from && !solanaTx) {
|
|
433
|
-
tx = tx.dexTxs[0]
|
|
434
|
-
tx.dexTxs = innerInstructions.slice(1)
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
const unparsed = Object.keys(tx).length === 0
|
|
443
|
-
|
|
444
|
-
if (unparsed && includeUnparsed) {
|
|
445
|
-
tx = getUnparsedTx(tx)
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// How tokens tx are parsed:
|
|
449
|
-
// 0. compute incoming or outgoing tx: it's outgoing if spl-token:transfer has source/destination included in tokenAccountsByOwner
|
|
450
|
-
// 1. if it's a sent tx: sum all instructions amount (spl-token:transfer)
|
|
451
|
-
// 2. if it's an incoming tx: sum all the amounts with destination included in tokenAccountsByOwner (aggregating by ticker)
|
|
452
|
-
// QUESTION: How do I know what are my tokens addresses deterministically? It's not possible, gotta use tokenAccountsByOwner
|
|
453
|
-
|
|
454
|
-
return {
|
|
455
|
-
id: txDetails.transaction.signatures[0],
|
|
456
|
-
slot: txDetails.slot,
|
|
457
|
-
error: !(txDetails.meta.err === null),
|
|
458
|
-
...tx,
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
async getSupply(mintAddress: string): string {
|
|
463
|
-
const {
|
|
464
|
-
value: { amount },
|
|
465
|
-
} = await this.api.post({
|
|
466
|
-
method: 'getTokenSupply',
|
|
467
|
-
params: [mintAddress],
|
|
468
|
-
})
|
|
469
|
-
return amount
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
async getWalletTokensList({ address, tokenAccounts }) {
|
|
473
|
-
const tokensMint = []
|
|
474
|
-
for (let account of tokenAccounts) {
|
|
475
|
-
const mint = account.mintAddress
|
|
476
|
-
|
|
477
|
-
// skip cached NFT
|
|
478
|
-
if (this.tokensToSkip[mint]) continue
|
|
479
|
-
// skip 0 balance
|
|
480
|
-
if (account.balance === '0') continue
|
|
481
|
-
// skip NFT
|
|
482
|
-
const supply = await this.getSupply(mint)
|
|
483
|
-
if (supply === '1') {
|
|
484
|
-
this.tokensToSkip[mint] = true
|
|
485
|
-
continue
|
|
486
|
-
}
|
|
487
|
-
// OK
|
|
488
|
-
tokensMint.push(mint)
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
return tokensMint
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
async getTokenAccountsByOwner(address: string, tokenTicker: ?string): Array {
|
|
495
|
-
const { value: accountsList } = await this.api.post({
|
|
496
|
-
method: 'getTokenAccountsByOwner',
|
|
497
|
-
params: [address, { programId: TOKEN_PROGRAM_ID.toBase58() }, { encoding: 'jsonParsed' }],
|
|
498
|
-
})
|
|
499
|
-
|
|
500
|
-
const tokenAccounts = []
|
|
501
|
-
for (let entry of accountsList) {
|
|
502
|
-
const { pubkey, account } = entry
|
|
503
|
-
|
|
504
|
-
const mint = lodash.get(account, 'data.parsed.info.mint')
|
|
505
|
-
const token = this.tokens[mint] || {
|
|
506
|
-
name: 'unknown',
|
|
507
|
-
ticker: 'UNKNOWN',
|
|
508
|
-
}
|
|
509
|
-
const balance = lodash.get(account, 'data.parsed.info.tokenAmount.amount', '0')
|
|
510
|
-
tokenAccounts.push({
|
|
511
|
-
tokenAccountAddress: pubkey,
|
|
512
|
-
owner: address,
|
|
513
|
-
tokenName: token.name,
|
|
514
|
-
ticker: token.ticker,
|
|
515
|
-
balance,
|
|
516
|
-
mintAddress: mint,
|
|
517
|
-
})
|
|
518
|
-
}
|
|
519
|
-
// eventually filter by token
|
|
520
|
-
return tokenTicker
|
|
521
|
-
? tokenAccounts.filter(({ ticker }) => ticker === tokenTicker)
|
|
522
|
-
: tokenAccounts
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
async getTokensBalance({ address, filterByTokens = [], tokenAccounts }) {
|
|
526
|
-
let accounts = tokenAccounts || (await this.getTokenAccountsByOwner(address))
|
|
527
|
-
|
|
528
|
-
const tokensBalance = accounts.reduce((acc, { tokenName, balance }) => {
|
|
529
|
-
if (tokenName === 'unknown' || (filterByTokens.length && !filterByTokens.includes(tokenName)))
|
|
530
|
-
return acc // filter by supported tokens only
|
|
531
|
-
if (!acc[tokenName]) acc[tokenName] = Number(balance)
|
|
532
|
-
// e.g { 'serum': 123 }
|
|
533
|
-
else acc[tokenName] += Number(balance) // merge same token account balance
|
|
534
|
-
return acc
|
|
535
|
-
}, {})
|
|
536
|
-
|
|
537
|
-
return tokensBalance
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
async isAssociatedTokenAccountActive(tokenAddress: string) {
|
|
541
|
-
// Returns the token balance of an SPL Token account.
|
|
542
|
-
try {
|
|
543
|
-
await this.api.post({
|
|
544
|
-
method: 'getTokenAccountBalance',
|
|
545
|
-
params: [tokenAddress],
|
|
546
|
-
})
|
|
547
|
-
return true
|
|
548
|
-
} catch (e) {
|
|
549
|
-
return false
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// Returns account balance of a SPL Token account.
|
|
554
|
-
async getTokenBalance(tokenAddress: string) {
|
|
555
|
-
const {
|
|
556
|
-
value: { amount },
|
|
557
|
-
} = await this.api.post({
|
|
558
|
-
method: 'getTokenAccountBalance',
|
|
559
|
-
params: [tokenAddress],
|
|
560
|
-
})
|
|
561
|
-
|
|
562
|
-
return amount
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
async getAccountInfo(address: string) {
|
|
566
|
-
const { value } = await this.api.post({
|
|
567
|
-
method: 'getAccountInfo',
|
|
568
|
-
params: [address, { encoding: 'jsonParsed', commitment: 'single' }],
|
|
569
|
-
})
|
|
570
|
-
return value
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
async isSpl(address: string) {
|
|
574
|
-
const { owner } = await this.getAccountInfo(address)
|
|
575
|
-
return owner === 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
async getMetaplexMetadata(tokenMintAddress: string) {
|
|
579
|
-
const metaplexPDA = getMetadataAccount(tokenMintAddress)
|
|
580
|
-
const res = await this.getAccountInfo(metaplexPDA)
|
|
581
|
-
const data = lodash.get(res, 'data[0]')
|
|
582
|
-
if (!data) return null
|
|
583
|
-
|
|
584
|
-
return deserializeMetaplexMetadata(Buffer.from(data, 'base64'))
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
async getDecimals(tokenMintAddress: string) {
|
|
588
|
-
const res = await this.api.post({ method: 'getTokenSupply', params: [tokenMintAddress] })
|
|
589
|
-
return lodash.get(res, 'value.decimals', null)
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
async getAddressType(address: string) {
|
|
593
|
-
// solana, token or null (unknown), meaning address has never been initialized
|
|
594
|
-
const value = await this.getAccountInfo(address)
|
|
595
|
-
if (value === null) return null
|
|
596
|
-
|
|
597
|
-
const account = {
|
|
598
|
-
executable: value.executable,
|
|
599
|
-
owner: value.owner,
|
|
600
|
-
lamports: value.lamports,
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
return account.owner === SYSTEM_PROGRAM_ID.toBase58()
|
|
604
|
-
? 'solana'
|
|
605
|
-
: account.owner === TOKEN_PROGRAM_ID.toBase58()
|
|
606
|
-
? 'token'
|
|
607
|
-
: null
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
async getTokenAddressOwner(address: string) {
|
|
611
|
-
const value = await this.getAccountInfo(address)
|
|
612
|
-
const owner = lodash.get(value, 'data.parsed.info.owner', null)
|
|
613
|
-
return owner
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
async getAddressMint(address) {
|
|
617
|
-
const value = await this.getAccountInfo(address)
|
|
618
|
-
const mintAddress = lodash.get(value, 'data.parsed.info.mint', null) // token mint
|
|
619
|
-
return mintAddress
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
async isTokenAddress(address: string) {
|
|
623
|
-
const type = await this.getAddressType(address)
|
|
624
|
-
return type === 'token'
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
async isSOLaddress(address: string) {
|
|
628
|
-
const type = await this.getAddressType(address)
|
|
629
|
-
return type === 'solana'
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
async getStakeAccountsInfo(address: string) {
|
|
633
|
-
// get staked amount and other info
|
|
634
|
-
const res = await this.api.post({
|
|
635
|
-
method: 'getProgramAccounts',
|
|
636
|
-
params: [
|
|
637
|
-
STAKE_PROGRAM_ID.toBase58(),
|
|
638
|
-
{
|
|
639
|
-
filters: [
|
|
640
|
-
{
|
|
641
|
-
memcmp: {
|
|
642
|
-
offset: 12,
|
|
643
|
-
bytes: address,
|
|
644
|
-
},
|
|
645
|
-
},
|
|
646
|
-
],
|
|
647
|
-
encoding: 'jsonParsed',
|
|
648
|
-
},
|
|
649
|
-
],
|
|
650
|
-
})
|
|
651
|
-
const accounts = {}
|
|
652
|
-
let totalStake = 0
|
|
653
|
-
let locked = 0
|
|
654
|
-
let withdrawable = 0
|
|
655
|
-
let pending = 0
|
|
656
|
-
for (let entry of res) {
|
|
657
|
-
const addr = entry.pubkey
|
|
658
|
-
const lamports = lodash.get(entry, 'account.lamports', 0)
|
|
659
|
-
const delegation = lodash.get(entry, 'account.data.parsed.info.stake.delegation', {})
|
|
660
|
-
// could have no delegation if the created stake address did not perform a delegate transaction
|
|
661
|
-
|
|
662
|
-
accounts[addr] = delegation
|
|
663
|
-
accounts[addr].lamports = lamports // sol balance
|
|
664
|
-
accounts[addr].activationEpoch = Number(accounts[addr].activationEpoch) || 0
|
|
665
|
-
accounts[addr].deactivationEpoch = Number(accounts[addr].deactivationEpoch) || 0
|
|
666
|
-
let state = 'inactive'
|
|
667
|
-
if (delegation.activationEpoch) state = await this.getStakeActivation(addr)
|
|
668
|
-
accounts[addr].state = state
|
|
669
|
-
accounts[addr].isDeactivating = state === 'deactivating'
|
|
670
|
-
accounts[addr].canWithdraw = state === 'inactive'
|
|
671
|
-
accounts[addr].stake = Number(accounts[addr].stake) || 0 // active staked amount
|
|
672
|
-
totalStake += accounts[addr].stake
|
|
673
|
-
locked += ['active', 'activating'].includes(accounts[addr].state) ? lamports : 0
|
|
674
|
-
withdrawable += accounts[addr].canWithdraw ? lamports : 0
|
|
675
|
-
pending += accounts[addr].isDeactivating ? lamports : 0
|
|
676
|
-
}
|
|
677
|
-
return { accounts, totalStake, locked, withdrawable, pending }
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
async getRewards(stakingAddresses = []) {
|
|
681
|
-
if (!stakingAddresses.length) return 0
|
|
682
|
-
|
|
683
|
-
// custom endpoint!
|
|
684
|
-
const rewards = await this.request(`rewards?addresses=${stakingAddresses.join(',')}`)
|
|
685
|
-
.get()
|
|
686
|
-
.error(500, () => ({})) // addresses not found
|
|
687
|
-
.error(400, () => ({}))
|
|
688
|
-
.json()
|
|
689
|
-
|
|
690
|
-
// sum rewards for all addresses
|
|
691
|
-
const earnings = Object.values(rewards).reduce((total, x) => {
|
|
692
|
-
return total + x
|
|
693
|
-
}, 0)
|
|
694
|
-
|
|
695
|
-
return earnings
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
async getMinimumBalanceForRentExemption(size: number) {
|
|
699
|
-
const minimumBalance = await this.api.post({
|
|
700
|
-
method: 'getMinimumBalanceForRentExemption',
|
|
701
|
-
params: [size],
|
|
702
|
-
})
|
|
703
|
-
|
|
704
|
-
return minimumBalance
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
/**
|
|
708
|
-
* Broadcast a signed transaction
|
|
709
|
-
*/
|
|
710
|
-
broadcastTransaction = async (signedTx: string): string => {
|
|
711
|
-
console.log('Solana broadcasting TX:', signedTx) // base64
|
|
712
|
-
|
|
713
|
-
const result = await this.api.post({
|
|
714
|
-
method: 'sendTransaction',
|
|
715
|
-
params: [signedTx, { encoding: 'base64', commitment: 'singleGossip' }],
|
|
716
|
-
})
|
|
717
|
-
|
|
718
|
-
console.log(`tx ${JSON.stringify(result)} sent!`)
|
|
719
|
-
return result || null
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
simulateTransaction = async (encodedTransaction, options) => {
|
|
723
|
-
const {
|
|
724
|
-
value: { accounts },
|
|
725
|
-
} = await this.api.post({
|
|
726
|
-
method: 'simulateTransaction',
|
|
727
|
-
params: [encodedTransaction, options],
|
|
728
|
-
})
|
|
729
|
-
|
|
730
|
-
return accounts
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
resolveSimulationSideEffects = async (solAccounts, tokenAccounts) => {
|
|
734
|
-
const willReceive = []
|
|
735
|
-
const willSend = []
|
|
736
|
-
|
|
737
|
-
const resolveSols = solAccounts.map(async (account) => {
|
|
738
|
-
const currentAmount = await this.getBalance(account.address)
|
|
739
|
-
const balance = computeBalance(account.amount, currentAmount)
|
|
740
|
-
return {
|
|
741
|
-
name: 'SOL',
|
|
742
|
-
symbol: 'SOL',
|
|
743
|
-
balance,
|
|
744
|
-
decimal: SOL_DECIMAL,
|
|
745
|
-
type: 'SOL',
|
|
746
|
-
}
|
|
747
|
-
})
|
|
748
|
-
|
|
749
|
-
const _wrapAndHandleAccountNotFound = (fn, defaultValue) => {
|
|
750
|
-
return async (...params) => {
|
|
751
|
-
try {
|
|
752
|
-
return await fn.apply(this, params)
|
|
753
|
-
} catch (error) {
|
|
754
|
-
if (error.message && error.message.includes('could not find account')) {
|
|
755
|
-
return defaultValue
|
|
756
|
-
}
|
|
757
|
-
throw error
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
const _getTokenBalance = _wrapAndHandleAccountNotFound(this.getTokenBalance, '0')
|
|
763
|
-
const _getDecimals = _wrapAndHandleAccountNotFound(this.getDecimals, 0)
|
|
764
|
-
const _getSupply = _wrapAndHandleAccountNotFound(this.getSupply, '0')
|
|
765
|
-
|
|
766
|
-
const resolveTokens = tokenAccounts.map(async (account) => {
|
|
767
|
-
try {
|
|
768
|
-
const [_tokenMetaPlex, currentAmount, decimal] = await Promise.all([
|
|
769
|
-
this.getMetaplexMetadata(account.mint),
|
|
770
|
-
_getTokenBalance(account.address),
|
|
771
|
-
_getDecimals(account.mint),
|
|
772
|
-
])
|
|
773
|
-
|
|
774
|
-
const tokenMetaPlex = _tokenMetaPlex || { name: null, symbol: null }
|
|
775
|
-
let nft = {
|
|
776
|
-
collectionId: null,
|
|
777
|
-
collectionName: null,
|
|
778
|
-
collectionTitle: null,
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
// Only perform an NFT check (getSupply) if decimal is zero
|
|
782
|
-
if (decimal === 0 && (await _getSupply(account.mint)) === '1') {
|
|
783
|
-
try {
|
|
784
|
-
const {
|
|
785
|
-
id: collectionId,
|
|
786
|
-
collectionName,
|
|
787
|
-
collectionTitle,
|
|
788
|
-
} = await magicEden.api.getNFTByMintAddress(account.mint)
|
|
789
|
-
nft = {
|
|
790
|
-
collectionId,
|
|
791
|
-
collectionTitle,
|
|
792
|
-
collectionName,
|
|
793
|
-
}
|
|
794
|
-
tokenMetaPlex.name = tokenMetaPlex.name || collectionTitle
|
|
795
|
-
tokenMetaPlex.symbol = tokenMetaPlex.symbol || collectionName
|
|
796
|
-
} catch (error) {
|
|
797
|
-
console.warn(error)
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
const balance = computeBalance(account.amount, currentAmount)
|
|
802
|
-
return {
|
|
803
|
-
balance,
|
|
804
|
-
decimal,
|
|
805
|
-
nft,
|
|
806
|
-
address: account.address,
|
|
807
|
-
mint: account.mint,
|
|
808
|
-
name: tokenMetaPlex.name,
|
|
809
|
-
symbol: tokenMetaPlex.symbol,
|
|
810
|
-
type: 'TOKEN',
|
|
811
|
-
}
|
|
812
|
-
} catch (error) {
|
|
813
|
-
console.warn(error)
|
|
814
|
-
return {
|
|
815
|
-
balance: null,
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
})
|
|
819
|
-
|
|
820
|
-
const accounts = await Promise.all([...resolveSols, ...resolveTokens])
|
|
821
|
-
accounts.forEach((account) => {
|
|
822
|
-
if (account.balance === null) {
|
|
823
|
-
return
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
if (account.balance > 0) {
|
|
827
|
-
willReceive.push(account)
|
|
828
|
-
} else {
|
|
829
|
-
willSend.push(account)
|
|
830
|
-
}
|
|
831
|
-
})
|
|
832
|
-
|
|
833
|
-
return {
|
|
834
|
-
willReceive,
|
|
835
|
-
willSend,
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
/**
|
|
840
|
-
* Simulate transaction and return side effects
|
|
841
|
-
*/
|
|
842
|
-
simulateAndRetrieveSideEffects = async (
|
|
843
|
-
transactionMessage: SolanaWeb3Message,
|
|
844
|
-
publicKey: string
|
|
845
|
-
) => {
|
|
846
|
-
const { config, accountAddresses } = getTransactionSimulationParams(transactionMessage)
|
|
847
|
-
const signatures = new Array(transactionMessage.header.numRequiredSignatures || 1).fill(null)
|
|
848
|
-
const encodedTransaction = buildRawTransaction(
|
|
849
|
-
transactionMessage.serialize(),
|
|
850
|
-
signatures
|
|
851
|
-
).toString('base64')
|
|
852
|
-
const futureAccountsState = await this.simulateTransaction(encodedTransaction, config)
|
|
853
|
-
const { solAccounts, tokenAccounts } = filterAccountsByOwner(
|
|
854
|
-
futureAccountsState,
|
|
855
|
-
accountAddresses,
|
|
856
|
-
publicKey
|
|
857
|
-
)
|
|
858
|
-
|
|
859
|
-
return this.resolveSimulationSideEffects(solAccounts, tokenAccounts)
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
export default new Api()
|
|
1
|
+
export * from './api'
|
|
2
|
+
export * from './tx-log'
|
|
3
|
+
export * from './account-state'
|