@exodus/solana-api 1.2.12 → 1.2.14

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.
Files changed (3) hide show
  1. package/lib/index.js +512 -0
  2. package/package.json +5 -6
  3. package/src/index.js +0 -431
package/lib/index.js ADDED
@@ -0,0 +1,512 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = void 0;
7
+
8
+ var _assetJsonRpc = _interopRequireDefault(require("@exodus/asset-json-rpc"));
9
+
10
+ var _solanaLib = require("@exodus/solana-lib");
11
+
12
+ var _assert = _interopRequireDefault(require("assert"));
13
+
14
+ var _lodash = _interopRequireDefault(require("lodash"));
15
+
16
+ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
17
+
18
+ function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; }
19
+
20
+ function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; }
21
+
22
+ function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
23
+
24
+ // Doc: https://docs.solana.com/apps/jsonrpc-api
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
+ // Tokens + SOL api support
27
+
28
+ class Api {
29
+ constructor(rpcUrl) {
30
+ _defineProperty(this, "broadcastTransaction", async signedTx => {
31
+ console.log('Solana broadcasting TX:', signedTx); // base64
32
+
33
+ const result = await this.api.post({
34
+ method: 'sendTransaction',
35
+ params: [signedTx, {
36
+ encoding: 'base64',
37
+ commitment: 'singleGossip'
38
+ }]
39
+ });
40
+ console.log(`tx ${JSON.stringify(result)} sent!`);
41
+ return result || null;
42
+ });
43
+
44
+ this.setServer(rpcUrl);
45
+ }
46
+
47
+ setServer(rpcUrl) {
48
+ this.rpcUrl = rpcUrl || RPC_URL;
49
+ this.api = (0, _assetJsonRpc.default)(this.rpcUrl);
50
+ }
51
+
52
+ async getCurrentEpoch() {
53
+ const {
54
+ epoch
55
+ } = await this.api.post({
56
+ method: 'getEpochInfo'
57
+ });
58
+ return Number(epoch);
59
+ }
60
+
61
+ async getStakeActivation(address) {
62
+ const {
63
+ state
64
+ } = await this.api.post({
65
+ method: 'getStakeActivation',
66
+ params: [address]
67
+ });
68
+ return state;
69
+ }
70
+
71
+ async getRecentBlockHash() {
72
+ const {
73
+ value: {
74
+ blockhash
75
+ }
76
+ } = await this.api.post({
77
+ method: 'getRecentBlockhash'
78
+ });
79
+ return blockhash;
80
+ } // Transaction structure: https://docs.solana.com/apps/jsonrpc-api#transaction-structure
81
+
82
+
83
+ async getTransactionById(id) {
84
+ const result = await this.api.post({
85
+ method: 'getConfirmedTransaction',
86
+ params: [id, 'jsonParsed']
87
+ });
88
+ return result;
89
+ }
90
+
91
+ async getFee() {
92
+ const {
93
+ value: {
94
+ feeCalculator: {
95
+ lamportsPerSignature
96
+ }
97
+ }
98
+ } = await this.api.post({
99
+ method: 'getRecentBlockhash'
100
+ });
101
+ return lamportsPerSignature;
102
+ }
103
+
104
+ async getBalance(address) {
105
+ const res = await this.api.post({
106
+ method: 'getBalance',
107
+ params: [address]
108
+ });
109
+ return res.value || 0;
110
+ }
111
+
112
+ async getBlockTime(slot) {
113
+ // might result in error if executed on a validator with partial ledger (https://github.com/solana-labs/solana/issues/12413)
114
+ return this.api.post({
115
+ method: 'getBlockTime',
116
+ params: [slot]
117
+ });
118
+ }
119
+
120
+ async getConfirmedSignaturesForAddress(address, {
121
+ until,
122
+ before,
123
+ limit
124
+ } = {}) {
125
+ until = until || undefined;
126
+ return this.api.post({
127
+ method: 'getConfirmedSignaturesForAddress2',
128
+ params: [address, {
129
+ until,
130
+ before,
131
+ limit
132
+ }]
133
+ });
134
+ }
135
+ /**
136
+ * Get transactions from an address
137
+ */
138
+
139
+
140
+ async getTransactions(address, {
141
+ cursor,
142
+ before,
143
+ limit
144
+ } = {}) {
145
+ let transactions = []; // cursor is a txHash
146
+
147
+ try {
148
+ let until = cursor;
149
+ const tokenAccountsByOwner = await this.getTokenAccountsByOwner(address); // Array
150
+
151
+ const tokenAccountAddresses = tokenAccountsByOwner.filter(({
152
+ tokenName
153
+ }) => tokenName !== 'unknown').map(({
154
+ tokenAccountAddress
155
+ }) => tokenAccountAddress);
156
+ const accountsToCheck = [address, ...tokenAccountAddresses];
157
+ const txsResultsByAccount = await Promise.all(accountsToCheck.map(addr => this.getConfirmedSignaturesForAddress(addr, {
158
+ until,
159
+ before,
160
+ limit
161
+ })));
162
+ let txsId = txsResultsByAccount.reduce((arr, row) => arr.concat(row), []); // merge arrays
163
+
164
+ txsId = _lodash.default.uniqBy(txsId, 'signature'); // get txs details in parallel
165
+
166
+ const txsDetails = await Promise.all(txsId.map(tx => this.getTransactionById(tx.signature)));
167
+ txsDetails.forEach(txDetail => {
168
+ if (txDetail === null) return;
169
+ const timestamp = txDetail.blockTime * 1000;
170
+ const parsedTx = Api.parseTransaction(address, txDetail, tokenAccountsByOwner);
171
+ if (!parsedTx.from) return; // cannot parse it
172
+
173
+ transactions.push(_objectSpread({
174
+ timestamp,
175
+ date: new Date(timestamp)
176
+ }, parsedTx));
177
+ });
178
+ } catch (err) {
179
+ console.warn('Solana error:', err);
180
+ throw err;
181
+ }
182
+
183
+ transactions = _lodash.default.orderBy(transactions, ['timestamp'], ['desc']);
184
+ const newCursor = transactions[0] ? transactions[0].id : cursor;
185
+ return {
186
+ transactions,
187
+ newCursor
188
+ };
189
+ }
190
+
191
+ parseTransaction(...args) {
192
+ // alias
193
+ return Api.parseTransaction(...args);
194
+ }
195
+
196
+ static parseTransaction(ownerAddress, txDetails, tokenAccountsByOwner) {
197
+ const {
198
+ fee
199
+ } = txDetails.meta;
200
+ let {
201
+ instructions
202
+ } = txDetails.transaction.message;
203
+ instructions = instructions.filter(ix => ix.parsed) // only known instructions
204
+ .map(ix => _objectSpread({
205
+ program: ix.program,
206
+ // system or spl-token
207
+ type: ix.parsed.type
208
+ }, ix.parsed.info)); // program:type tells us if it's a SOL or Token transfer
209
+
210
+ const solanaTx = _lodash.default.find(instructions, {
211
+ program: 'system',
212
+ type: 'transfer'
213
+ }); // get SOL transfer
214
+
215
+
216
+ const stakeTx = _lodash.default.find(instructions, {
217
+ program: 'system',
218
+ type: 'createAccountWithSeed'
219
+ });
220
+
221
+ const stakeWithdraw = _lodash.default.find(instructions, {
222
+ program: 'stake',
223
+ type: 'withdraw'
224
+ });
225
+
226
+ const stakeUndelegate = _lodash.default.find(instructions, {
227
+ program: 'stake',
228
+ type: 'deactivate'
229
+ });
230
+
231
+ let tx;
232
+
233
+ if (solanaTx) {
234
+ // Solana tx
235
+ const isSending = ownerAddress === solanaTx.source;
236
+ tx = {
237
+ owner: solanaTx.source,
238
+ from: solanaTx.source,
239
+ to: solanaTx.destination,
240
+ amount: solanaTx.lamports,
241
+ // number
242
+ fee: isSending ? fee : 0
243
+ };
244
+ } else if (stakeTx) {
245
+ // start staking
246
+ tx = {
247
+ owner: stakeTx.base,
248
+ from: stakeTx.base,
249
+ to: stakeTx.owner,
250
+ amount: stakeTx.lamports,
251
+ fee,
252
+ staking: {
253
+ method: 'createAccountWithSeed',
254
+ seed: stakeTx.seed,
255
+ stakeAddress: stakeTx.newAccount,
256
+ stake: stakeTx.lamports
257
+ }
258
+ };
259
+ } else if (stakeWithdraw) {
260
+ // TODO: lodash.find above returns 1 occurence, there could be multiple withdraw instructions in the same tx.
261
+ tx = {
262
+ owner: stakeWithdraw.withdrawAuthority,
263
+ from: stakeWithdraw.stakeAccount,
264
+ to: stakeWithdraw.destination,
265
+ amount: stakeWithdraw.lamports,
266
+ fee,
267
+ staking: {
268
+ method: 'withdraw',
269
+ stakeAddress: stakeWithdraw.stakeAccount,
270
+ stake: stakeWithdraw.lamports
271
+ }
272
+ };
273
+ } else if (stakeUndelegate) {
274
+ tx = {
275
+ owner: stakeUndelegate.stakeAuthority,
276
+ from: stakeUndelegate.stakeAuthority,
277
+ to: stakeUndelegate.stakeAccount,
278
+ amount: 0,
279
+ fee,
280
+ staking: {
281
+ method: 'undelegate',
282
+ stakeAddress: stakeUndelegate.stakeAccount
283
+ }
284
+ };
285
+ } else {
286
+ // Token tx
287
+ _assert.default.ok(Array.isArray(tokenAccountsByOwner), 'tokenAccountsByOwner is required when parsing token tx');
288
+
289
+ let tokenTxs = _lodash.default.filter(instructions, {
290
+ program: 'spl-token',
291
+ type: 'transfer'
292
+ }) // get Token transfer: could have more than 1 instructions
293
+ .map(ix => {
294
+ // add token details based on source/destination address
295
+ let tokenAccount = _lodash.default.find(tokenAccountsByOwner, {
296
+ tokenAccountAddress: ix.source
297
+ });
298
+
299
+ const isSending = !!tokenAccount;
300
+ if (!isSending) tokenAccount = _lodash.default.find(tokenAccountsByOwner, {
301
+ tokenAccountAddress: ix.destination
302
+ }); // receiving
303
+
304
+ if (!tokenAccount) return null; // no transfers with our addresses involved
305
+
306
+ const owner = isSending ? ownerAddress : null;
307
+ delete tokenAccount.balance;
308
+ delete tokenAccount.owner;
309
+ return {
310
+ owner,
311
+ token: tokenAccount,
312
+ from: ix.source,
313
+ to: ix.destination,
314
+ amount: Number(ix.amount),
315
+ // token
316
+ fee: isSending ? fee : 0 // in lamports
317
+
318
+ };
319
+ }); // .reduce to sum/sub (based on isSending) all the same tokens amount (From instructions -> 1 single tx)
320
+
321
+
322
+ tx = tokenTxs.reduce((finalTx, ix) => {
323
+ if (!ix) return finalTx; // skip null instructions
324
+
325
+ if (!finalTx.token) return ix; // init finalTx (support just 1 token type per tx)
326
+
327
+ if (finalTx.token.ticker === ix.token.ticker) finalTx.amount += ix.amount;
328
+ return finalTx;
329
+ }, {});
330
+ } // How tokens tx are parsed:
331
+ // 0. compute incoming or outgoing tx: it's outgoing if spl-token:transfer has source/destination included in tokenAccountsByOwner
332
+ // 1. if it's a sent tx: sum all instructions amount (spl-token:transfer)
333
+ // 2. if it's an incoming tx: sull all the amounts with destination included in tokenAccountsByOwner (aggregating by ticker)
334
+ // QUESTION: How do I know what are my tokens addresses deterministically? It's not possible, gotta use tokenAccountsByOwner
335
+
336
+
337
+ return _objectSpread({
338
+ id: txDetails.transaction.signatures[0],
339
+ slot: txDetails.slot,
340
+ error: !(txDetails.meta.err === null)
341
+ }, tx);
342
+ }
343
+
344
+ async getTokenAccountsByOwner(address, tokenTicker) {
345
+ const {
346
+ value: accountsList
347
+ } = await this.api.post({
348
+ method: 'getTokenAccountsByOwner',
349
+ params: [address, {
350
+ programId: _solanaLib.TOKEN_PROGRAM_ID.toBase58()
351
+ }, {
352
+ encoding: 'jsonParsed'
353
+ }]
354
+ });
355
+ const tokenAccounts = [];
356
+
357
+ for (let entry of accountsList) {
358
+ const {
359
+ pubkey,
360
+ account
361
+ } = entry;
362
+
363
+ const mint = _lodash.default.get(account, 'data.parsed.info.mint');
364
+
365
+ const token = _solanaLib.tokens.find(({
366
+ mintAddress
367
+ }) => mintAddress === mint) || {
368
+ tokenName: 'unknown',
369
+ tokenSymbol: 'UNKNOWN'
370
+ };
371
+
372
+ const balance = _lodash.default.get(account, 'data.parsed.info.tokenAmount.amount', '0');
373
+
374
+ tokenAccounts.push({
375
+ tokenAccountAddress: pubkey,
376
+ owner: address,
377
+ tokenName: token.tokenName,
378
+ ticker: token.tokenSymbol,
379
+ balance
380
+ });
381
+ } // eventually filter by token
382
+
383
+
384
+ return tokenTicker ? tokenAccounts.filter(({
385
+ ticker
386
+ }) => ticker === tokenTicker) : tokenAccounts;
387
+ }
388
+
389
+ async getTokensBalance(address, filterByTokens = []) {
390
+ let accounts = await this.getTokenAccountsByOwner(address); // Tokens
391
+
392
+ const tokensBalance = accounts.reduce((acc, {
393
+ tokenName,
394
+ balance
395
+ }) => {
396
+ if (tokenName === 'unknown' || filterByTokens.length && !filterByTokens.includes(tokenName)) return acc; // filter by supported tokens only
397
+
398
+ if (!acc[tokenName]) acc[tokenName] = Number(balance); // e.g { 'serum': 123 }
399
+ else acc[tokenName] += Number(balance); // merge same token account balance
400
+
401
+ return acc;
402
+ }, {});
403
+ return tokensBalance;
404
+ }
405
+
406
+ async isAssociatedTokenAccountActive(tokenAddress) {
407
+ // Returns the token balance of an SPL Token account.
408
+ try {
409
+ await this.api.post({
410
+ method: 'getTokenAccountBalance',
411
+ params: [tokenAddress]
412
+ });
413
+ return true;
414
+ } catch (e) {
415
+ return false;
416
+ }
417
+ }
418
+
419
+ async getAddressType(address) {
420
+ // solana, token or null (unknown), meaning address has never been initialized
421
+ const {
422
+ value
423
+ } = await this.api.post({
424
+ method: 'getAccountInfo',
425
+ params: [address, {
426
+ encoding: 'base64'
427
+ }]
428
+ });
429
+ if (value === null) return null;
430
+ const account = {
431
+ executable: value.executable,
432
+ owner: value.owner,
433
+ lamports: value.lamports,
434
+ data: value.data
435
+ };
436
+ return account.owner === _solanaLib.SYSTEM_PROGRAM_ID.toBase58() ? 'solana' : account.owner === _solanaLib.TOKEN_PROGRAM_ID.toBase58() ? 'token' : null;
437
+ }
438
+
439
+ async isTokenAddress(address) {
440
+ const type = await this.getAddressType(address);
441
+ return type === 'token';
442
+ }
443
+
444
+ async isSOLaddress(address) {
445
+ const type = await this.getAddressType(address);
446
+ return type === 'solana';
447
+ }
448
+
449
+ async getStakeAccountsInfo(address) {
450
+ // get staked amount and other info
451
+ const res = await this.api.post({
452
+ method: 'getProgramAccounts',
453
+ params: [_solanaLib.STAKE_PROGRAM_ID.toBase58(), {
454
+ filters: [{
455
+ memcmp: {
456
+ offset: 12,
457
+ bytes: address
458
+ }
459
+ }],
460
+ encoding: 'jsonParsed'
461
+ }]
462
+ });
463
+ const accounts = {};
464
+ let totalStake = 0;
465
+ let locked = 0;
466
+ let withdrawable = 0;
467
+ let pending = 0;
468
+
469
+ for (let entry of res) {
470
+ const addr = entry.pubkey;
471
+
472
+ const lamports = _lodash.default.get(entry, 'account.lamports', 0);
473
+
474
+ const delegation = _lodash.default.get(entry, 'account.data.parsed.info.stake.delegation', {}); // could have no delegation if the created stake address did not perform a delegate transaction
475
+
476
+
477
+ accounts[addr] = delegation;
478
+ accounts[addr].lamports = lamports; // sol balance
479
+
480
+ accounts[addr].activationEpoch = Number(accounts[addr].activationEpoch) || 0;
481
+ accounts[addr].deactivationEpoch = Number(accounts[addr].deactivationEpoch) || 0;
482
+ let state = 'inactive';
483
+ if (delegation.activationEpoch) state = await this.getStakeActivation(addr);
484
+ accounts[addr].state = state;
485
+ accounts[addr].isDeactivating = state === 'deactivating';
486
+ accounts[addr].canWithdraw = state === 'inactive';
487
+ accounts[addr].stake = Number(accounts[addr].stake) || 0; // active staked amount
488
+
489
+ totalStake += accounts[addr].stake;
490
+ locked += ['active', 'activating'].includes(accounts[addr].state) ? lamports : 0;
491
+ withdrawable += accounts[addr].canWithdraw ? lamports : 0;
492
+ pending += accounts[addr].isDeactivating ? lamports : 0;
493
+ }
494
+
495
+ return {
496
+ accounts,
497
+ totalStake,
498
+ locked,
499
+ withdrawable,
500
+ pending
501
+ };
502
+ }
503
+ /**
504
+ * Broadcast a signed transaction
505
+ */
506
+
507
+
508
+ }
509
+
510
+ var _default = new Api();
511
+
512
+ exports.default = _default;
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@exodus/solana-api",
3
- "version": "1.2.12",
3
+ "version": "1.2.14",
4
4
  "description": "Exodus internal Solana asset API wrapper",
5
- "main": "src/index.js",
5
+ "main": "lib/index.js",
6
6
  "files": [
7
- "src/",
7
+ "lib/",
8
8
  "!src/__tests__"
9
9
  ],
10
10
  "author": "Exodus",
@@ -14,12 +14,11 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@exodus/asset-json-rpc": "^1.0.0",
17
- "@exodus/solana-lib": "^1.2.12",
17
+ "@exodus/solana-lib": "^1.2.14",
18
18
  "lodash": "^4.17.11",
19
19
  "wretch": "^1.5.2"
20
20
  },
21
21
  "devDependencies": {
22
22
  "node-fetch": "~1.6.3"
23
- },
24
- "gitHead": "41e9ea0fdbade75891e954ea56d83a983f18c6ae"
23
+ }
25
24
  }
package/src/index.js DELETED
@@ -1,431 +0,0 @@
1
- // @flow
2
- import createApi from '@exodus/asset-json-rpc'
3
- import { tokens, SYSTEM_PROGRAM_ID, STAKE_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@exodus/solana-lib'
4
- import assert from 'assert'
5
- import lodash from 'lodash'
6
-
7
- // Doc: https://docs.solana.com/apps/jsonrpc-api
8
-
9
- 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
10
-
11
- // Tokens + SOL api support
12
- class Api {
13
- constructor(rpcUrl) {
14
- this.setServer(rpcUrl)
15
- }
16
-
17
- setServer(rpcUrl) {
18
- this.rpcUrl = rpcUrl || RPC_URL
19
- this.api = createApi(this.rpcUrl)
20
- }
21
-
22
- async getCurrentEpoch(): number {
23
- const { epoch } = await this.api.post({
24
- method: 'getEpochInfo',
25
- })
26
- return Number(epoch)
27
- }
28
-
29
- async getStakeActivation(address): string {
30
- const { state } = await this.api.post({
31
- method: 'getStakeActivation',
32
- params: [address],
33
- })
34
- return state
35
- }
36
-
37
- async getRecentBlockHash(): string {
38
- const {
39
- value: { blockhash },
40
- } = await this.api.post({
41
- method: 'getRecentBlockhash',
42
- })
43
- return blockhash
44
- }
45
-
46
- // Transaction structure: https://docs.solana.com/apps/jsonrpc-api#transaction-structure
47
- async getTransactionById(id: string) {
48
- const result = await this.api.post({
49
- method: 'getConfirmedTransaction',
50
- params: [id, 'jsonParsed'],
51
- })
52
- return result
53
- }
54
-
55
- async getFee(): number {
56
- const {
57
- value: {
58
- feeCalculator: { lamportsPerSignature },
59
- },
60
- } = await this.api.post({
61
- method: 'getRecentBlockhash',
62
- })
63
- return lamportsPerSignature
64
- }
65
-
66
- async getBalance(address: string): number {
67
- const res = await this.api.post({
68
- method: 'getBalance',
69
- params: [address],
70
- })
71
- return res.value || 0
72
- }
73
-
74
- async getBlockTime(slot: number) {
75
- // might result in error if executed on a validator with partial ledger (https://github.com/solana-labs/solana/issues/12413)
76
- return this.api.post({
77
- method: 'getBlockTime',
78
- params: [slot],
79
- })
80
- }
81
-
82
- async getConfirmedSignaturesForAddress(address: string, { until, before, limit } = {}): any {
83
- until = until || undefined
84
- return this.api.post({
85
- method: 'getConfirmedSignaturesForAddress2',
86
- params: [address, { until, before, limit }],
87
- })
88
- }
89
-
90
- /**
91
- * Get transactions from an address
92
- */
93
- async getTransactions(address: string, { cursor, before, limit } = {}): any {
94
- let transactions = []
95
- // cursor is a txHash
96
-
97
- try {
98
- let until = cursor
99
-
100
- const tokenAccountsByOwner = await this.getTokenAccountsByOwner(address) // Array
101
- const tokenAccountAddresses = tokenAccountsByOwner
102
- .filter(({ tokenName }) => tokenName !== 'unknown')
103
- .map(({ tokenAccountAddress }) => tokenAccountAddress)
104
- const accountsToCheck = [address, ...tokenAccountAddresses]
105
-
106
- const txsResultsByAccount = await Promise.all(
107
- accountsToCheck.map((addr) =>
108
- this.getConfirmedSignaturesForAddress(addr, {
109
- until,
110
- before,
111
- limit,
112
- })
113
- )
114
- )
115
- let txsId = txsResultsByAccount.reduce((arr, row) => arr.concat(row), []) // merge arrays
116
- txsId = lodash.uniqBy(txsId, 'signature')
117
-
118
- // get txs details in parallel
119
- const txsDetails = await Promise.all(txsId.map((tx) => this.getTransactionById(tx.signature)))
120
- txsDetails.forEach((txDetail) => {
121
- if (txDetail === null) return
122
-
123
- const timestamp = txDetail.blockTime * 1000
124
- const parsedTx = Api.parseTransaction(address, txDetail, tokenAccountsByOwner)
125
- if (!parsedTx.from) return // cannot parse it
126
-
127
- transactions.push({
128
- timestamp,
129
- date: new Date(timestamp),
130
- ...parsedTx,
131
- })
132
- })
133
- } catch (err) {
134
- console.warn('Solana error:', err)
135
- throw err
136
- }
137
-
138
- transactions = lodash.orderBy(transactions, ['timestamp'], ['desc'])
139
-
140
- const newCursor = transactions[0] ? transactions[0].id : cursor
141
-
142
- return { transactions, newCursor }
143
- }
144
-
145
- parseTransaction(...args) {
146
- // alias
147
- return Api.parseTransaction(...args)
148
- }
149
-
150
- static parseTransaction(
151
- ownerAddress: string,
152
- txDetails: Object,
153
- tokenAccountsByOwner: ?Array
154
- ): Object {
155
- const { fee } = txDetails.meta
156
- let { instructions } = txDetails.transaction.message
157
- instructions = instructions
158
- .filter((ix) => ix.parsed) // only known instructions
159
- .map((ix) => ({
160
- program: ix.program, // system or spl-token
161
- type: ix.parsed.type, // transfer, createAccount, initializeAccount
162
- ...ix.parsed.info,
163
- }))
164
-
165
- // program:type tells us if it's a SOL or Token transfer
166
- const solanaTx = lodash.find(instructions, { program: 'system', type: 'transfer' }) // get SOL transfer
167
- const stakeTx = lodash.find(instructions, { program: 'system', type: 'createAccountWithSeed' })
168
- const stakeWithdraw = lodash.find(instructions, { program: 'stake', type: 'withdraw' })
169
- const stakeUndelegate = lodash.find(instructions, { program: 'stake', type: 'deactivate' })
170
-
171
- let tx
172
- if (solanaTx) {
173
- // Solana tx
174
- const isSending = ownerAddress === solanaTx.source
175
- tx = {
176
- owner: solanaTx.source,
177
- from: solanaTx.source,
178
- to: solanaTx.destination,
179
- amount: solanaTx.lamports, // number
180
- fee: isSending ? fee : 0,
181
- }
182
- } else if (stakeTx) {
183
- // start staking
184
- tx = {
185
- owner: stakeTx.base,
186
- from: stakeTx.base,
187
- to: stakeTx.owner,
188
- amount: stakeTx.lamports,
189
- fee,
190
- staking: {
191
- method: 'createAccountWithSeed',
192
- seed: stakeTx.seed,
193
- stakeAddress: stakeTx.newAccount,
194
- stake: stakeTx.lamports,
195
- },
196
- }
197
- } else if (stakeWithdraw) {
198
- // TODO: lodash.find above returns 1 occurence, there could be multiple withdraw instructions in the same tx.
199
- tx = {
200
- owner: stakeWithdraw.withdrawAuthority,
201
- from: stakeWithdraw.stakeAccount,
202
- to: stakeWithdraw.destination,
203
- amount: stakeWithdraw.lamports,
204
- fee,
205
- staking: {
206
- method: 'withdraw',
207
- stakeAddress: stakeWithdraw.stakeAccount,
208
- stake: stakeWithdraw.lamports,
209
- },
210
- }
211
- } else if (stakeUndelegate) {
212
- tx = {
213
- owner: stakeUndelegate.stakeAuthority,
214
- from: stakeUndelegate.stakeAuthority,
215
- to: stakeUndelegate.stakeAccount,
216
- amount: 0,
217
- fee,
218
- staking: {
219
- method: 'undelegate',
220
- stakeAddress: stakeUndelegate.stakeAccount,
221
- },
222
- }
223
- } else {
224
- // Token tx
225
- assert.ok(
226
- Array.isArray(tokenAccountsByOwner),
227
- 'tokenAccountsByOwner is required when parsing token tx'
228
- )
229
- let tokenTxs = lodash
230
- .filter(instructions, { program: 'spl-token', type: 'transfer' }) // get Token transfer: could have more than 1 instructions
231
- .map((ix) => {
232
- // add token details based on source/destination address
233
- let tokenAccount = lodash.find(tokenAccountsByOwner, { tokenAccountAddress: ix.source })
234
- const isSending = !!tokenAccount
235
- if (!isSending)
236
- tokenAccount = lodash.find(tokenAccountsByOwner, {
237
- tokenAccountAddress: ix.destination,
238
- }) // receiving
239
- if (!tokenAccount) return null // no transfers with our addresses involved
240
- const owner = isSending ? ownerAddress : null
241
-
242
- delete tokenAccount.balance
243
- delete tokenAccount.owner
244
- return {
245
- owner,
246
- token: tokenAccount,
247
- from: ix.source,
248
- to: ix.destination,
249
- amount: Number(ix.amount), // token
250
- fee: isSending ? fee : 0, // in lamports
251
- }
252
- })
253
-
254
- // .reduce to sum/sub (based on isSending) all the same tokens amount (From instructions -> 1 single tx)
255
- tx = tokenTxs.reduce((finalTx, ix) => {
256
- if (!ix) return finalTx // skip null instructions
257
- if (!finalTx.token) return ix // init finalTx (support just 1 token type per tx)
258
- if (finalTx.token.ticker === ix.token.ticker) finalTx.amount += ix.amount
259
- return finalTx
260
- }, {})
261
- }
262
-
263
- // How tokens tx are parsed:
264
- // 0. compute incoming or outgoing tx: it's outgoing if spl-token:transfer has source/destination included in tokenAccountsByOwner
265
- // 1. if it's a sent tx: sum all instructions amount (spl-token:transfer)
266
- // 2. if it's an incoming tx: sull all the amounts with destination included in tokenAccountsByOwner (aggregating by ticker)
267
- // QUESTION: How do I know what are my tokens addresses deterministically? It's not possible, gotta use tokenAccountsByOwner
268
-
269
- return {
270
- id: txDetails.transaction.signatures[0],
271
- slot: txDetails.slot,
272
- error: !(txDetails.meta.err === null),
273
- ...tx,
274
- }
275
- }
276
-
277
- async getTokenAccountsByOwner(address: string, tokenTicker: ?string): Array {
278
- const { value: accountsList } = await this.api.post({
279
- method: 'getTokenAccountsByOwner',
280
- params: [address, { programId: TOKEN_PROGRAM_ID.toBase58() }, { encoding: 'jsonParsed' }],
281
- })
282
-
283
- const tokenAccounts = []
284
- for (let entry of accountsList) {
285
- const { pubkey, account } = entry
286
-
287
- const mint = lodash.get(account, 'data.parsed.info.mint')
288
- const token = tokens.find(({ mintAddress }) => mintAddress === mint) || {
289
- tokenName: 'unknown',
290
- tokenSymbol: 'UNKNOWN',
291
- }
292
- const balance = lodash.get(account, 'data.parsed.info.tokenAmount.amount', '0')
293
- tokenAccounts.push({
294
- tokenAccountAddress: pubkey,
295
- owner: address,
296
- tokenName: token.tokenName,
297
- ticker: token.tokenSymbol,
298
- balance,
299
- })
300
- }
301
- // eventually filter by token
302
- return tokenTicker
303
- ? tokenAccounts.filter(({ ticker }) => ticker === tokenTicker)
304
- : tokenAccounts
305
- }
306
-
307
- async getTokensBalance(address: string, filterByTokens = []) {
308
- let accounts = await this.getTokenAccountsByOwner(address) // Tokens
309
-
310
- const tokensBalance = accounts.reduce((acc, { tokenName, balance }) => {
311
- if (tokenName === 'unknown' || (filterByTokens.length && !filterByTokens.includes(tokenName)))
312
- return acc // filter by supported tokens only
313
- if (!acc[tokenName]) acc[tokenName] = Number(balance)
314
- // e.g { 'serum': 123 }
315
- else acc[tokenName] += Number(balance) // merge same token account balance
316
- return acc
317
- }, {})
318
-
319
- return tokensBalance
320
- }
321
-
322
- async isAssociatedTokenAccountActive(tokenAddress: string) {
323
- // Returns the token balance of an SPL Token account.
324
- try {
325
- await this.api.post({
326
- method: 'getTokenAccountBalance',
327
- params: [tokenAddress],
328
- })
329
- return true
330
- } catch (e) {
331
- return false
332
- }
333
- }
334
-
335
- async getAddressType(address: string) {
336
- // solana, token or null (unknown), meaning address has never been initialized
337
- const { value } = await this.api.post({
338
- method: 'getAccountInfo',
339
- params: [address, { encoding: 'base64' }],
340
- })
341
- if (value === null) return null
342
-
343
- const account = {
344
- executable: value.executable,
345
- owner: value.owner,
346
- lamports: value.lamports,
347
- data: value.data,
348
- }
349
-
350
- return account.owner === SYSTEM_PROGRAM_ID.toBase58()
351
- ? 'solana'
352
- : account.owner === TOKEN_PROGRAM_ID.toBase58()
353
- ? 'token'
354
- : null
355
- }
356
-
357
- async isTokenAddress(address: string) {
358
- const type = await this.getAddressType(address)
359
- return type === 'token'
360
- }
361
-
362
- async isSOLaddress(address: string) {
363
- const type = await this.getAddressType(address)
364
- return type === 'solana'
365
- }
366
-
367
- async getStakeAccountsInfo(address: string) {
368
- // get staked amount and other info
369
- const res = await this.api.post({
370
- method: 'getProgramAccounts',
371
- params: [
372
- STAKE_PROGRAM_ID.toBase58(),
373
- {
374
- filters: [
375
- {
376
- memcmp: {
377
- offset: 12,
378
- bytes: address,
379
- },
380
- },
381
- ],
382
- encoding: 'jsonParsed',
383
- },
384
- ],
385
- })
386
- const accounts = {}
387
- let totalStake = 0
388
- let locked = 0
389
- let withdrawable = 0
390
- let pending = 0
391
- for (let entry of res) {
392
- const addr = entry.pubkey
393
- const lamports = lodash.get(entry, 'account.lamports', 0)
394
- const delegation = lodash.get(entry, 'account.data.parsed.info.stake.delegation', {})
395
- // could have no delegation if the created stake address did not perform a delegate transaction
396
-
397
- accounts[addr] = delegation
398
- accounts[addr].lamports = lamports // sol balance
399
- accounts[addr].activationEpoch = Number(accounts[addr].activationEpoch) || 0
400
- accounts[addr].deactivationEpoch = Number(accounts[addr].deactivationEpoch) || 0
401
- let state = 'inactive'
402
- if (delegation.activationEpoch) state = await this.getStakeActivation(addr)
403
- accounts[addr].state = state
404
- accounts[addr].isDeactivating = state === 'deactivating'
405
- accounts[addr].canWithdraw = state === 'inactive'
406
- accounts[addr].stake = Number(accounts[addr].stake) || 0 // active staked amount
407
- totalStake += accounts[addr].stake
408
- locked += ['active', 'activating'].includes(accounts[addr].state) ? lamports : 0
409
- withdrawable += accounts[addr].canWithdraw ? lamports : 0
410
- pending += accounts[addr].isDeactivating ? lamports : 0
411
- }
412
- return { accounts, totalStake, locked, withdrawable, pending }
413
- }
414
-
415
- /**
416
- * Broadcast a signed transaction
417
- */
418
- broadcastTransaction = async (signedTx: string): string => {
419
- console.log('Solana broadcasting TX:', signedTx) // base64
420
-
421
- const result = await this.api.post({
422
- method: 'sendTransaction',
423
- params: [signedTx, { encoding: 'base64', commitment: 'singleGossip' }],
424
- })
425
-
426
- console.log(`tx ${JSON.stringify(result)} sent!`)
427
- return result || null
428
- }
429
- }
430
-
431
- export default new Api()