@exodus/solana-api 1.2.7 → 1.2.9-build

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 +510 -0
  2. package/package.json +5 -6
  3. package/src/index.js +0 -431
package/lib/index.js ADDED
@@ -0,0 +1,510 @@
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');
165
+
166
+ for (let tx of txsId) {
167
+ // get tx details
168
+ const [txDetails, blockTime] = await Promise.all([this.getTransactionById(tx.signature), this.getBlockTime(tx.slot)]);
169
+ if (txDetails === null) continue;
170
+ const timestamp = blockTime * 1000;
171
+ transactions.push(_objectSpread({
172
+ timestamp,
173
+ date: new Date(timestamp)
174
+ }, Api.parseTransaction(address, txDetails, tokenAccountsByOwner)));
175
+ }
176
+ } catch (err) {
177
+ console.warn('Solana error:', err);
178
+ throw err;
179
+ }
180
+
181
+ transactions = _lodash.default.orderBy(transactions, ['timestamp'], ['desc']);
182
+ const newCursor = transactions[0] ? transactions[0].id : cursor;
183
+ return {
184
+ transactions,
185
+ newCursor
186
+ };
187
+ }
188
+
189
+ parseTransaction(...args) {
190
+ // alias
191
+ return Api.parseTransaction(...args);
192
+ }
193
+
194
+ static parseTransaction(ownerAddress, txDetails, tokenAccountsByOwner) {
195
+ const {
196
+ fee
197
+ } = txDetails.meta;
198
+ let {
199
+ instructions
200
+ } = txDetails.transaction.message;
201
+ instructions = instructions.filter(ix => ix.parsed) // only known instructions
202
+ .map(ix => _objectSpread({
203
+ program: ix.program,
204
+ // system or spl-token
205
+ type: ix.parsed.type
206
+ }, ix.parsed.info)); // program:type tells us if it's a SOL or Token transfer
207
+
208
+ const solanaTx = _lodash.default.find(instructions, {
209
+ program: 'system',
210
+ type: 'transfer'
211
+ }); // get SOL transfer
212
+
213
+
214
+ const stakeTx = _lodash.default.find(instructions, {
215
+ program: 'system',
216
+ type: 'createAccountWithSeed'
217
+ });
218
+
219
+ const stakeWithdraw = _lodash.default.find(instructions, {
220
+ program: 'stake',
221
+ type: 'withdraw'
222
+ });
223
+
224
+ const stakeUndelegate = _lodash.default.find(instructions, {
225
+ program: 'stake',
226
+ type: 'deactivate'
227
+ });
228
+
229
+ let tx;
230
+
231
+ if (solanaTx) {
232
+ // Solana tx
233
+ const isSending = ownerAddress === solanaTx.source;
234
+ tx = {
235
+ owner: solanaTx.source,
236
+ from: solanaTx.source,
237
+ to: solanaTx.destination,
238
+ amount: solanaTx.lamports,
239
+ // number
240
+ fee: isSending ? fee : 0
241
+ };
242
+ } else if (stakeTx) {
243
+ // start staking
244
+ tx = {
245
+ owner: stakeTx.base,
246
+ from: stakeTx.base,
247
+ to: stakeTx.owner,
248
+ amount: stakeTx.lamports,
249
+ fee,
250
+ staking: {
251
+ method: 'createAccountWithSeed',
252
+ seed: stakeTx.seed,
253
+ stakeAddress: stakeTx.newAccount,
254
+ stake: stakeTx.lamports
255
+ }
256
+ };
257
+ } else if (stakeWithdraw) {
258
+ // TODO: lodash.find above returns 1 occurence, there could be multiple withdraw instructions in the same tx.
259
+ tx = {
260
+ owner: stakeWithdraw.withdrawAuthority,
261
+ from: stakeWithdraw.stakeAccount,
262
+ to: stakeWithdraw.destination,
263
+ amount: stakeWithdraw.lamports,
264
+ fee,
265
+ staking: {
266
+ method: 'withdraw',
267
+ stakeAddress: stakeWithdraw.stakeAccount,
268
+ stake: stakeWithdraw.lamports
269
+ }
270
+ };
271
+ } else if (stakeUndelegate) {
272
+ tx = {
273
+ owner: stakeUndelegate.stakeAuthority,
274
+ from: stakeUndelegate.stakeAuthority,
275
+ to: stakeUndelegate.stakeAccount,
276
+ amount: 0,
277
+ fee,
278
+ staking: {
279
+ method: 'undelegate',
280
+ stakeAddress: stakeUndelegate.stakeAccount
281
+ }
282
+ };
283
+ } else {
284
+ // Token tx
285
+ _assert.default.ok(Array.isArray(tokenAccountsByOwner), 'tokenAccountsByOwner is required when parsing token tx');
286
+
287
+ let tokenTxs = _lodash.default.filter(instructions, {
288
+ program: 'spl-token',
289
+ type: 'transfer'
290
+ }) // get Token transfer: could have more than 1 instructions
291
+ .map(ix => {
292
+ // add token details based on source/destination address
293
+ let tokenAccount = _lodash.default.find(tokenAccountsByOwner, {
294
+ tokenAccountAddress: ix.source
295
+ });
296
+
297
+ const isSending = !!tokenAccount;
298
+ if (!isSending) tokenAccount = _lodash.default.find(tokenAccountsByOwner, {
299
+ tokenAccountAddress: ix.destination
300
+ }); // receiving
301
+
302
+ if (!tokenAccount) return null; // no transfers with our addresses involved
303
+
304
+ const owner = isSending ? ownerAddress : null;
305
+ delete tokenAccount.balance;
306
+ delete tokenAccount.owner;
307
+ return {
308
+ owner,
309
+ token: tokenAccount,
310
+ from: ix.source,
311
+ to: ix.destination,
312
+ amount: Number(ix.amount),
313
+ // token
314
+ fee: isSending ? fee : 0 // in lamports
315
+
316
+ };
317
+ }); // .reduce to sum/sub (based on isSending) all the same tokens amount (From instructions -> 1 single tx)
318
+
319
+
320
+ tx = tokenTxs.reduce((finalTx, ix) => {
321
+ if (!ix) return finalTx; // skip null instructions
322
+
323
+ if (!finalTx.token) return ix; // init finalTx (support just 1 token type per tx)
324
+
325
+ if (finalTx.token.ticker === ix.token.ticker) finalTx.amount += ix.amount;
326
+ return finalTx;
327
+ }, {});
328
+ } // How tokens tx are parsed:
329
+ // 0. compute incoming or outgoing tx: it's outgoing if spl-token:transfer has source/destination included in tokenAccountsByOwner
330
+ // 1. if it's a sent tx: sum all instructions amount (spl-token:transfer)
331
+ // 2. if it's an incoming tx: sull all the amounts with destination included in tokenAccountsByOwner (aggregating by ticker)
332
+ // QUESTION: How do I know what are my tokens addresses deterministically? It's not possible, gotta use tokenAccountsByOwner
333
+
334
+
335
+ return _objectSpread({
336
+ id: txDetails.transaction.signatures[0],
337
+ slot: txDetails.slot,
338
+ error: !(txDetails.meta.err === null)
339
+ }, tx);
340
+ }
341
+
342
+ async getTokenAccountsByOwner(address, tokenTicker) {
343
+ const {
344
+ value: accountsList
345
+ } = await this.api.post({
346
+ method: 'getTokenAccountsByOwner',
347
+ params: [address, {
348
+ programId: _solanaLib.TOKEN_PROGRAM_ID.toBase58()
349
+ }, {
350
+ encoding: 'jsonParsed'
351
+ }]
352
+ });
353
+ const tokenAccounts = [];
354
+
355
+ for (let entry of accountsList) {
356
+ const {
357
+ pubkey,
358
+ account
359
+ } = entry;
360
+
361
+ const mint = _lodash.default.get(account, 'data.parsed.info.mint');
362
+
363
+ const token = _solanaLib.tokens.find(({
364
+ mintAddress
365
+ }) => mintAddress === mint) || {
366
+ tokenName: 'unknown',
367
+ tokenSymbol: 'UNKNOWN'
368
+ };
369
+
370
+ const balance = _lodash.default.get(account, 'data.parsed.info.tokenAmount.amount', '0');
371
+
372
+ tokenAccounts.push({
373
+ tokenAccountAddress: pubkey,
374
+ owner: address,
375
+ tokenName: token.tokenName,
376
+ ticker: token.tokenSymbol,
377
+ balance
378
+ });
379
+ } // eventually filter by token
380
+
381
+
382
+ return tokenTicker ? tokenAccounts.filter(({
383
+ ticker
384
+ }) => ticker === tokenTicker) : tokenAccounts;
385
+ }
386
+
387
+ async getTokensBalance(address, filterByTokens = []) {
388
+ let accounts = await this.getTokenAccountsByOwner(address); // Tokens
389
+
390
+ const tokensBalance = accounts.reduce((acc, {
391
+ tokenName,
392
+ balance
393
+ }) => {
394
+ if (tokenName === 'unknown' || filterByTokens.length && !filterByTokens.includes(tokenName)) return acc; // filter by supported tokens only
395
+
396
+ if (!acc[tokenName]) acc[tokenName] = Number(balance); // e.g { 'serum': 123 }
397
+ else acc[tokenName] += Number(balance); // merge same token account balance
398
+
399
+ return acc;
400
+ }, {});
401
+ return tokensBalance;
402
+ }
403
+
404
+ async isAssociatedTokenAccountActive(tokenAddress) {
405
+ // Returns the token balance of an SPL Token account.
406
+ try {
407
+ await this.api.post({
408
+ method: 'getTokenAccountBalance',
409
+ params: [tokenAddress]
410
+ });
411
+ return true;
412
+ } catch (e) {
413
+ return false;
414
+ }
415
+ }
416
+
417
+ async getAddressType(address) {
418
+ // solana, token or null (unknown), meaning address has never been initialized
419
+ const {
420
+ value
421
+ } = await this.api.post({
422
+ method: 'getAccountInfo',
423
+ params: [address, {
424
+ encoding: 'base64'
425
+ }]
426
+ });
427
+ if (value === null) return null;
428
+ const account = {
429
+ executable: value.executable,
430
+ owner: value.owner,
431
+ lamports: value.lamports,
432
+ data: value.data
433
+ };
434
+ return account.owner === _solanaLib.SYSTEM_PROGRAM_ID.toBase58() ? 'solana' : account.owner === _solanaLib.TOKEN_PROGRAM_ID.toBase58() ? 'token' : null;
435
+ }
436
+
437
+ async isTokenAddress(address) {
438
+ const type = await this.getAddressType(address);
439
+ return type === 'token';
440
+ }
441
+
442
+ async isSOLaddress(address) {
443
+ const type = await this.getAddressType(address);
444
+ return type === 'solana';
445
+ }
446
+
447
+ async getStakeAccountsInfo(address) {
448
+ // get staked amount and other info
449
+ const res = await this.api.post({
450
+ method: 'getProgramAccounts',
451
+ params: [_solanaLib.STAKE_PROGRAM_ID.toBase58(), {
452
+ filters: [{
453
+ memcmp: {
454
+ offset: 12,
455
+ bytes: address
456
+ }
457
+ }],
458
+ encoding: 'jsonParsed'
459
+ }]
460
+ });
461
+ const accounts = {};
462
+ let totalStake = 0;
463
+ let locked = 0;
464
+ let withdrawable = 0;
465
+ let pending = 0;
466
+
467
+ for (let entry of res) {
468
+ const addr = entry.pubkey;
469
+
470
+ const lamports = _lodash.default.get(entry, 'account.lamports', 0);
471
+
472
+ 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
473
+
474
+
475
+ accounts[addr] = delegation;
476
+ accounts[addr].lamports = lamports; // sol balance
477
+
478
+ accounts[addr].activationEpoch = Number(accounts[addr].activationEpoch) || 0;
479
+ accounts[addr].deactivationEpoch = Number(accounts[addr].deactivationEpoch) || 0;
480
+ let state = 'inactive';
481
+ if (delegation.activationEpoch) state = await this.getStakeActivation(addr);
482
+ accounts[addr].state = state;
483
+ accounts[addr].isDeactivating = state === 'deactivating';
484
+ accounts[addr].canWithdraw = state === 'inactive';
485
+ accounts[addr].stake = Number(accounts[addr].stake) || 0; // active staked amount
486
+
487
+ totalStake += accounts[addr].stake;
488
+ locked += accounts[addr].canWithdraw ? 0 : lamports;
489
+ withdrawable += accounts[addr].canWithdraw ? lamports : 0;
490
+ pending += accounts[addr].isDeactivating ? lamports : 0;
491
+ }
492
+
493
+ return {
494
+ accounts,
495
+ totalStake,
496
+ locked,
497
+ withdrawable,
498
+ pending
499
+ };
500
+ }
501
+ /**
502
+ * Broadcast a signed transaction
503
+ */
504
+
505
+
506
+ }
507
+
508
+ var _default = new Api();
509
+
510
+ exports.default = _default;
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@exodus/solana-api",
3
- "version": "1.2.7",
3
+ "version": "1.2.9-build",
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.7",
17
+ "@exodus/solana-lib": "^1.2.9",
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": "892c55db784616256e065a2c931a5e5611f5404f"
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
- for (let tx of txsId) {
119
- // get tx details
120
- const [txDetails, blockTime] = await Promise.all([
121
- this.getTransactionById(tx.signature),
122
- this.getBlockTime(tx.slot),
123
- ])
124
- if (txDetails === null) continue
125
-
126
- const timestamp = blockTime * 1000
127
- transactions.push({
128
- timestamp,
129
- date: new Date(timestamp),
130
- ...Api.parseTransaction(address, txDetails, tokenAccountsByOwner),
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 += accounts[addr].canWithdraw ? 0 : lamports
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()